feat(home): rotate greetings from a deterministic per-user daily pool
This commit is contained in:
parent
fad432a072
commit
b9c41e0cbf
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue