From b9c41e0cbfefdaaca39920f99790d92d1f71896e Mon Sep 17 00:00:00 2001 From: kolaente Date: Sun, 5 Apr 2026 13:39:14 +0200 Subject: [PATCH] feat(home): rotate greetings from a deterministic per-user daily pool --- .../composables/useDaytimeSalutation.test.ts | 122 ++++++++++++++++++ .../src/composables/useDaytimeSalutation.ts | 95 ++++++++++++-- 2 files changed, 204 insertions(+), 13 deletions(-) create mode 100644 frontend/src/composables/useDaytimeSalutation.test.ts diff --git a/frontend/src/composables/useDaytimeSalutation.test.ts b/frontend/src/composables/useDaytimeSalutation.test.ts new file mode 100644 index 000000000..5c7bb602e --- /dev/null +++ b/frontend/src/composables/useDaytimeSalutation.test.ts @@ -0,0 +1,122 @@ +import {describe, it, expect, beforeEach} from 'vitest' +import {setActivePinia, createPinia} from 'pinia' +import {createI18n} from 'vue-i18n' +import {defineComponent, h, ref, type Ref} from 'vue' +import {mount} from '@vue/test-utils' + +import {useDaytimeSalutation} from './useDaytimeSalutation' +import {useAuthStore} from '@/stores/auth' +import {AUTH_TYPES} from '@/modelTypes/IUser' +import en from '@/i18n/lang/en.json' + +function makeDate(iso: string): Date { + return new Date(iso) +} + +function makeI18n() { + return createI18n({ + legacy: false, + locale: 'en', + fallbackLocale: 'en', + messages: {en}, + }) +} + +function runSalutation(now: Ref): string | undefined { + let result: string | undefined + const Comp = defineComponent({ + setup() { + const s = useDaytimeSalutation(now) + result = s.value + return () => h('div') + }, + }) + mount(Comp, {global: {plugins: [makeI18n()]}}) + return result +} + +function setUser() { + const authStore = useAuthStore() + authStore.setUser({ + id: 42, + name: 'Ada', + username: 'ada', + type: AUTH_TYPES.LINK_SHARE, + created: new Date('2024-01-15T10:00:00Z'), + } as never, false) +} + +describe('useDaytimeSalutation', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('returns undefined when the user has no display name', () => { + const now = ref(makeDate('2026-04-06T09:00:00')) + expect(runSalutation(now)).toBeUndefined() + }) + + it('is deterministic for the same user, date, and bucket', () => { + setUser() + const now = ref(makeDate('2026-04-06T09:00:00')) + const first = runSalutation(now) + const second = runSalutation(now) + + expect(first).toBeDefined() + expect(first).toBe(second) + }) + + it('produces a string from the morning pool on a Monday morning', () => { + setUser() + const now = ref(makeDate('2026-04-06T09:00:00')) + const result = runSalutation(now) + + expect(result).toContain('Ada') + const morningStrings = [ + 'Good Morning Ada!', + 'Hey Ada, ready to go?', + 'Fresh start, Ada', + 'Coffee and tasks, Ada?', + 'Rise and plan, Ada', + 'Welcome back, Ada', + 'Fresh week, Ada', + ] + expect(morningStrings).toContain(result) + }) + + it('includes the Friday extra in the pool on Friday morning', () => { + setUser() + const reachable = new Set() + for (let day = 3; day <= 31; day += 7) { + const iso = `2026-04-${String(day).padStart(2, '0')}T09:00:00` + const r = runSalutation(ref(makeDate(iso))) + if (r) reachable.add(r) + } + expect(reachable.size).toBeGreaterThan(1) + }) + + it('uses different buckets for different hours', () => { + setUser() + const dateStr = '2026-04-06' + const morning = runSalutation(ref(makeDate(`${dateStr}T09:00:00`))) + const day = runSalutation(ref(makeDate(`${dateStr}T14:00:00`))) + const evening = runSalutation(ref(makeDate(`${dateStr}T20:00:00`))) + const night = runSalutation(ref(makeDate(`${dateStr}T02:00:00`))) + + expect(morning).toBeDefined() + expect(day).toBeDefined() + expect(evening).toBeDefined() + expect(night).toBeDefined() + expect(new Set([morning, day, evening, night]).size).toBeGreaterThan(1) + }) + + it('produces different results across consecutive days', () => { + setUser() + const results = new Set() + for (let day = 1; day <= 14; day++) { + const iso = `2026-04-${String(day).padStart(2, '0')}T09:00:00` + results.add(runSalutation(ref(makeDate(iso))) ?? '') + } + expect(results.size).toBeGreaterThan(1) + }) +}) diff --git a/frontend/src/composables/useDaytimeSalutation.ts b/frontend/src/composables/useDaytimeSalutation.ts index 5d18b5ecf..5f50df7e7 100644 --- a/frontend/src/composables/useDaytimeSalutation.ts +++ b/frontend/src/composables/useDaytimeSalutation.ts @@ -1,26 +1,95 @@ -import {computed, onActivated, ref} from 'vue' +import {computed, onActivated, ref, type Ref} from 'vue' import {useI18n} from 'vue-i18n' import {useAuthStore} from '@/stores/auth' import {hourToDaytime} from '@/helpers/hourToDaytime' +import {stringHash} from '@/helpers/stringHash' export type Daytime = 'night' | 'morning' | 'day' | 'evening' -export function useDaytimeSalutation() { +// Base i18n keys for each bucket. Existing keys (welcomeNight/Morning/Day/Evening) +// are kept as the first entry of their respective pool so prior translations remain valid. +const basePools: Record = { + night: [ + 'home.welcomeNight', + 'home.welcomeNightOwl', + 'home.welcomeNightBurning', + 'home.welcomeNightQuiet', + 'home.welcomeNightLate', + 'home.welcomeNightMoonlit', + ], + morning: [ + 'home.welcomeMorning', + 'home.welcomeMorningHey', + 'home.welcomeMorningFresh', + 'home.welcomeMorningCoffee', + 'home.welcomeMorningRise', + 'home.welcomeMorningBack', + ], + day: [ + 'home.welcomeDay', + 'home.welcomeDayBack', + 'home.welcomeDayFocus', + 'home.welcomeDayKeepGoing', + 'home.welcomeDayWhatsNext', + 'home.welcomeDayGood', + ], + evening: [ + 'home.welcomeEvening', + 'home.welcomeEveningWind', + 'home.welcomeEveningReturns', + 'home.welcomeEveningWrap', + 'home.welcomeEveningOneMore', + 'home.welcomeEveningStill', + ], +} + +// One entry per weekday (index = Date.getDay(), Sunday = 0). Appended to the +// morning pool only, on its matching day. +const morningWeekdayExtras: (string | null)[] = [ + 'home.welcomeSundaySession', // 0 Sun + 'home.welcomeMondayFresh', // 1 Mon + 'home.welcomeTuesday', // 2 Tue + 'home.welcomeWednesdayMid', // 3 Wed + 'home.welcomeThursday', // 4 Thu + 'home.welcomeFridayPush', // 5 Fri + 'home.welcomeSaturday', // 6 Sat +] + +function poolFor(bucket: Daytime, now: Date): string[] { + if (bucket !== 'morning') { + return basePools[bucket] + } + const extra = morningWeekdayExtras[now.getDay()] + return extra ? [...basePools.morning, extra] : basePools.morning +} + +function dateKey(now: Date): string { + return `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}` +} + +export function useDaytimeSalutation(now?: Ref) { const {t} = useI18n({useScope: 'global'}) - const now = ref(new Date()) - onActivated(() => now.value = new Date()) + const internalNow = ref(new Date()) + const currentDate = now ?? internalNow + onActivated(() => { + internalNow.value = new Date() + }) const authStore = useAuthStore() const name = computed(() => authStore.userDisplayName) - const daytime = computed(() => hourToDaytime(now.value)) + // Use the user's created timestamp as the per-user hash component. + // It's stable, unique per user, and doesn't leak the sequential user id. + const userKey = computed(() => authStore.info?.created?.getTime() ?? 0) + const bucket = computed(() => hourToDaytime(currentDate.value)) - const salutations = { - 'night': () => t('home.welcomeNight', {username: name.value}), - 'morning': () => t('home.welcomeMorning', {username: name.value}), - 'day': () => t('home.welcomeDay', {username: name.value}), - 'evening': () => t('home.welcomeEvening', {username: name.value}), - } as Record string> - - return computed(() => name.value ? salutations[daytime.value]() : undefined) + return computed(() => { + if (!name.value) { + return undefined + } + const pool = poolFor(bucket.value, currentDate.value) + const key = `${dateKey(currentDate.value)}_${bucket.value}_${userKey.value}` + const index = stringHash(key) % pool.length + return t(pool[index], {username: name.value}) + }) }