feat(home): rotate greetings from a deterministic per-user daily pool

This commit is contained in:
kolaente 2026-04-05 13:39:14 +02:00 committed by kolaente
parent fad432a072
commit b9c41e0cbf
2 changed files with 204 additions and 13 deletions

View File

@ -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<Date>): 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<string>()
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<string>()
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)
})
})

View File

@ -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<Daytime, string[]> = {
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<Date>) {
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<Daytime, () => 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})
})
}