mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 20:49:47 +02:00
improve home page
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
chooseHomeGreeting,
|
||||
getDayAwareGreetings,
|
||||
getHomeGreetingBucket,
|
||||
getTimeAwareGreetings,
|
||||
HOME_PLAYFUL_GREETINGS,
|
||||
parseHomeGreetingTextMode,
|
||||
} from './homeGreeting.ts';
|
||||
|
||||
function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date {
|
||||
return new Date(year, month - 1, day, hour, minute, 0, 0);
|
||||
}
|
||||
|
||||
test('defaults missing and invalid greeting modes to messages', () => {
|
||||
assert.equal(parseHomeGreetingTextMode(null), 'messages');
|
||||
assert.equal(parseHomeGreetingTextMode(''), 'messages');
|
||||
assert.equal(parseHomeGreetingTextMode('weather'), 'messages');
|
||||
assert.equal(parseHomeGreetingTextMode('messages'), 'messages');
|
||||
assert.equal(parseHomeGreetingTextMode('clock'), 'clock');
|
||||
assert.equal(parseHomeGreetingTextMode('off'), 'off');
|
||||
});
|
||||
|
||||
test('uses exact time-window boundaries', () => {
|
||||
const starts: Array<[number, string]> = [
|
||||
[0, 'time-midnight'],
|
||||
[180, 'time-three-am'],
|
||||
[300, 'time-early-morning'],
|
||||
[420, 'time-morning'],
|
||||
[660, 'time-late-morning'],
|
||||
[720, 'time-early-afternoon'],
|
||||
[840, 'time-afternoon'],
|
||||
[1020, 'time-sunset'],
|
||||
[1080, 'time-evening'],
|
||||
[1380, 'time-late-night'],
|
||||
];
|
||||
|
||||
for (const [minute, id] of starts) {
|
||||
const hour = Math.floor(minute / 60);
|
||||
const minuteOfHour = minute % 60;
|
||||
assert.equal(getTimeAwareGreetings(localDate(2026, 7, 14, hour, minuteOfHour))[0].id, id);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the approved mobile playful copy intact', () => {
|
||||
assert.equal(HOME_PLAYFUL_GREETINGS.length, 15);
|
||||
assert.deepEqual(HOME_PLAYFUL_GREETINGS.at(-2), {
|
||||
id: 'playful-pocket',
|
||||
primary: 'I fit in your pocket now.',
|
||||
subline: 'This was a mistake.',
|
||||
});
|
||||
assert.deepEqual(HOME_PLAYFUL_GREETINGS.at(-1), {
|
||||
id: 'playful-rectangle',
|
||||
primary: 'This rectangle demands music.',
|
||||
subline: 'Obey.',
|
||||
});
|
||||
assert.equal(
|
||||
HOME_PLAYFUL_GREETINGS.some((message) => message.primary === 'Pocket concert.'),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('only supplies day-aware greetings on the selected weekdays', () => {
|
||||
assert.equal(getDayAwareGreetings(localDate(2026, 7, 13, 12))[0].id, 'day-monday');
|
||||
assert.equal(getDayAwareGreetings(localDate(2026, 7, 14, 12)).length, 0);
|
||||
assert.equal(getDayAwareGreetings(localDate(2026, 7, 15, 12))[0].id, 'day-wednesday');
|
||||
assert.equal(getDayAwareGreetings(localDate(2026, 7, 17, 12))[0].id, 'day-friday');
|
||||
assert.equal(getDayAwareGreetings(localDate(2026, 7, 19, 12))[0].id, 'day-sunday');
|
||||
});
|
||||
|
||||
test('maps local time to the four adaptive tint buckets', () => {
|
||||
assert.equal(getHomeGreetingBucket(localDate(2026, 7, 14, 4, 59)), 'late-night');
|
||||
assert.equal(getHomeGreetingBucket(localDate(2026, 7, 14, 5, 0)), 'morning');
|
||||
assert.equal(getHomeGreetingBucket(localDate(2026, 7, 14, 12, 0)), 'afternoon');
|
||||
assert.equal(getHomeGreetingBucket(localDate(2026, 7, 14, 18, 0)), 'evening');
|
||||
assert.equal(getHomeGreetingBucket(localDate(2026, 7, 14, 23, 0)), 'late-night');
|
||||
});
|
||||
|
||||
test('uses deterministic weighted selection for time, day, and playful pools', () => {
|
||||
const mondayNoon = localDate(2026, 7, 13, 12);
|
||||
assert.equal(chooseHomeGreeting(null, mondayNoon, () => 0).id, 'time-early-afternoon');
|
||||
assert.equal(chooseHomeGreeting(null, mondayNoon, () => 0.5).id, 'day-monday');
|
||||
|
||||
const values = [0.99, 0.999];
|
||||
assert.equal(
|
||||
chooseHomeGreeting(null, mondayNoon, () => values.shift() ?? 0).id,
|
||||
'playful-rectangle'
|
||||
);
|
||||
});
|
||||
|
||||
test('avoids immediately repeating the previous message', () => {
|
||||
const mondayNoon = localDate(2026, 7, 13, 12);
|
||||
const values = [0, 0, 0];
|
||||
const next = chooseHomeGreeting(
|
||||
'time-early-afternoon',
|
||||
mondayNoon,
|
||||
() => values.shift() ?? 0
|
||||
);
|
||||
assert.notEqual(next.id, 'time-early-afternoon');
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
export type HomeGreetingTextMode = 'messages' | 'clock' | 'off';
|
||||
export type HomeGreetingBucket = 'morning' | 'afternoon' | 'evening' | 'late-night';
|
||||
|
||||
export interface HomeGreetingCopy {
|
||||
id: string;
|
||||
primary: string;
|
||||
subline: string;
|
||||
}
|
||||
|
||||
export interface HomeGreetingSelection extends HomeGreetingCopy {
|
||||
bucket: HomeGreetingBucket;
|
||||
}
|
||||
|
||||
interface TimeGreetingWindow {
|
||||
startMinute: number;
|
||||
endMinute: number;
|
||||
messages: readonly HomeGreetingCopy[];
|
||||
}
|
||||
|
||||
interface WeightedGreetingPool {
|
||||
messages: readonly HomeGreetingCopy[];
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export const HOME_GREETING_ROTATION_MS = 30 * 60 * 1000;
|
||||
|
||||
const GREETING_WEIGHT_TIME_AWARE = 0.4;
|
||||
const GREETING_WEIGHT_DAY_AWARE = 0.28;
|
||||
const GREETING_WEIGHT_PLAYFUL = 0.32;
|
||||
|
||||
export const HOME_PLAYFUL_GREETINGS: readonly HomeGreetingCopy[] = [
|
||||
{ id: 'playful-back-again', primary: 'Back again.', subline: 'Your music missed you.' },
|
||||
{ id: 'playful-silence', primary: 'Silence?', subline: 'Nuh uh.' },
|
||||
{ id: 'playful-aux', primary: 'The aux is yours.', subline: 'Don\'t mess this up.' },
|
||||
{ id: 'playful-no-algorithm', primary: 'No algorithm.', subline: 'Just you.' },
|
||||
{ id: 'playful-headphones', primary: 'Headphones on.', subline: 'World off.' },
|
||||
{ id: 'playful-one-more', primary: 'One more song.', subline: 'Famous last words.' },
|
||||
{ id: 'playful-tiny-screen', primary: 'Tiny screen.', subline: 'Big library.' },
|
||||
{ id: 'playful-shuffle', primary: 'Shuffle responsibly.', subline: 'Or don\'t.' },
|
||||
{ id: 'playful-queue', primary: 'Your queue called.', subline: 'It has concerns.' },
|
||||
{ id: 'playful-local-files', primary: 'Local files.', subline: 'Radical concept, apparently.' },
|
||||
{ id: 'playful-airplane', primary: 'Airplane mode?', subline: 'Still works.' },
|
||||
{ id: 'playful-portable', primary: 'Now portable.', subline: 'Please don\'t drop me.' },
|
||||
{ id: 'playful-hey', primary: 'hey…', subline: 'does anyone even read these?' },
|
||||
{ id: 'playful-pocket', primary: 'I fit in your pocket now.', subline: 'This was a mistake.' },
|
||||
{ id: 'playful-rectangle', primary: 'This rectangle demands music.', subline: 'Obey.' },
|
||||
];
|
||||
|
||||
const TIME_AWARE_GREETINGS: readonly TimeGreetingWindow[] = [
|
||||
{
|
||||
startMinute: 0,
|
||||
endMinute: 180,
|
||||
messages: [{ id: 'time-midnight', primary: 'Still up?', subline: 'Go to sleep.' }],
|
||||
},
|
||||
{
|
||||
startMinute: 180,
|
||||
endMinute: 300,
|
||||
messages: [{ id: 'time-three-am', primary: '3 AM again, huh', subline: 'The void has music in it.' }],
|
||||
},
|
||||
{
|
||||
startMinute: 300,
|
||||
endMinute: 420,
|
||||
messages: [{ id: 'time-early-morning', primary: 'Morning.', subline: 'It\'s too early.' }],
|
||||
},
|
||||
{
|
||||
startMinute: 420,
|
||||
endMinute: 660,
|
||||
messages: [{ id: 'time-morning', primary: 'Good morning!', subline: 'Pick a soundtrack.' }],
|
||||
},
|
||||
{
|
||||
startMinute: 660,
|
||||
endMinute: 720,
|
||||
messages: [{ id: 'time-late-morning', primary: 'Late morning.', subline: 'Coffee acquired?' }],
|
||||
},
|
||||
{
|
||||
startMinute: 720,
|
||||
endMinute: 840,
|
||||
messages: [{ id: 'time-early-afternoon', primary: 'Good afternoon.', subline: 'Halfway there.' }],
|
||||
},
|
||||
{
|
||||
startMinute: 840,
|
||||
endMinute: 1020,
|
||||
messages: [{ id: 'time-afternoon', primary: 'Afternoon stretch.', subline: 'One more push.' }],
|
||||
},
|
||||
{
|
||||
startMinute: 1020,
|
||||
endMinute: 1080,
|
||||
messages: [{ id: 'time-sunset', primary: 'Sunset switch.', subline: 'Set the evening tone.' }],
|
||||
},
|
||||
{
|
||||
startMinute: 1080,
|
||||
endMinute: 1380,
|
||||
messages: [{ id: 'time-evening', primary: 'Good evening.', subline: 'The night is yours.' }],
|
||||
},
|
||||
{
|
||||
startMinute: 1380,
|
||||
endMinute: 1440,
|
||||
messages: [{ id: 'time-late-night', primary: 'Late night?', subline: 'Same.' }],
|
||||
},
|
||||
];
|
||||
|
||||
export function parseHomeGreetingTextMode(value: string | null): HomeGreetingTextMode {
|
||||
return value === 'clock' || value === 'off' ? value : 'messages';
|
||||
}
|
||||
|
||||
export function getHomeGreetingBucket(date: Date): HomeGreetingBucket {
|
||||
const hour = date.getHours();
|
||||
if (hour >= 5 && hour <= 11) return 'morning';
|
||||
if (hour >= 12 && hour <= 17) return 'afternoon';
|
||||
if (hour >= 18 && hour <= 22) return 'evening';
|
||||
return 'late-night';
|
||||
}
|
||||
|
||||
export function getTimeAwareGreetings(date: Date): readonly HomeGreetingCopy[] {
|
||||
const minuteOfDay = date.getHours() * 60 + date.getMinutes();
|
||||
return TIME_AWARE_GREETINGS.find(
|
||||
(entry) => minuteOfDay >= entry.startMinute && minuteOfDay < entry.endMinute
|
||||
)?.messages ?? [];
|
||||
}
|
||||
|
||||
export function getDayAwareGreetings(date: Date): readonly HomeGreetingCopy[] {
|
||||
switch (date.getDay()) {
|
||||
case 1:
|
||||
return [{ id: 'day-monday', primary: 'Monday.', subline: 'Let\'s fix that.' }];
|
||||
case 3:
|
||||
return [{ id: 'day-wednesday', primary: 'It\'s Wednesday somehow.', subline: '' }];
|
||||
case 5:
|
||||
return [{ id: 'day-friday', primary: 'It\'s Friday.', subline: 'You made it.' }];
|
||||
case 0:
|
||||
return [{ id: 'day-sunday', primary: 'Sunday already?', subline: 'Put something good on.' }];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function boundedRandom(random: () => number): number {
|
||||
const value = random();
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(0.999999999, Math.max(0, value));
|
||||
}
|
||||
|
||||
function pickRandomGreeting(
|
||||
messages: readonly HomeGreetingCopy[],
|
||||
previousId: string | null,
|
||||
random: () => number
|
||||
): HomeGreetingCopy {
|
||||
const candidates = previousId
|
||||
? messages.filter((message) => message.id !== previousId)
|
||||
: messages;
|
||||
const pool = candidates.length > 0 ? candidates : messages;
|
||||
return pool[Math.floor(boundedRandom(random) * pool.length)] ?? HOME_PLAYFUL_GREETINGS[0];
|
||||
}
|
||||
|
||||
function pickWeightedGreetingPool(
|
||||
pools: readonly WeightedGreetingPool[],
|
||||
random: () => number
|
||||
): WeightedGreetingPool {
|
||||
const totalWeight = pools.reduce((sum, pool) => sum + pool.weight, 0);
|
||||
let threshold = boundedRandom(random) * totalWeight;
|
||||
for (const pool of pools) {
|
||||
threshold -= pool.weight;
|
||||
if (threshold <= 0) return pool;
|
||||
}
|
||||
return pools[pools.length - 1];
|
||||
}
|
||||
|
||||
export function chooseHomeGreeting(
|
||||
previousId: string | null,
|
||||
now: Date,
|
||||
random: () => number = Math.random
|
||||
): HomeGreetingSelection {
|
||||
const timeAware = getTimeAwareGreetings(now);
|
||||
const dayAware = getDayAwareGreetings(now);
|
||||
const pools: WeightedGreetingPool[] = [
|
||||
{ messages: timeAware, weight: GREETING_WEIGHT_TIME_AWARE },
|
||||
...(dayAware.length > 0
|
||||
? [{ messages: dayAware, weight: GREETING_WEIGHT_DAY_AWARE }]
|
||||
: []),
|
||||
{ messages: HOME_PLAYFUL_GREETINGS, weight: GREETING_WEIGHT_PLAYFUL },
|
||||
];
|
||||
|
||||
const selectedPool = pickWeightedGreetingPool(pools, random);
|
||||
let greeting = pickRandomGreeting(selectedPool.messages, previousId, random);
|
||||
|
||||
if (previousId && greeting.id === previousId) {
|
||||
const alternatives = pools
|
||||
.flatMap((pool) => pool.messages)
|
||||
.filter((candidate) => candidate.id !== previousId);
|
||||
if (alternatives.length > 0) {
|
||||
greeting = pickRandomGreeting(alternatives, previousId, random);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...greeting, bucket: getHomeGreetingBucket(now) };
|
||||
}
|
||||
Reference in New Issue
Block a user