diff --git a/client/src/components/Chat/Landing.tsx b/client/src/components/Chat/Landing.tsx index f0aaecbbf8..b2620b91e0 100644 --- a/client/src/components/Chat/Landing.tsx +++ b/client/src/components/Chat/Landing.tsx @@ -12,9 +12,9 @@ import { } from '~/utils'; import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers'; import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider'; +import { useLocalize, useAuthContext, useGreeting } from '~/hooks'; import AgentContact from '~/components/Agents/AgentContact'; import ConvoIcon from '~/components/Endpoints/ConvoIcon'; -import { useLocalize, useAuthContext } from '~/hooks'; const containerClassName = 'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-presentation text-text-primary dark:after:shadow-none '; @@ -29,7 +29,7 @@ function getTextSizeClass(text: string | undefined | null) { return 'text-xl sm:text-2xl'; } - if (text.length < 40) { + if (text.length < 56) { return 'text-2xl sm:text-4xl'; } @@ -97,42 +97,12 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding: const selectedAgent = isAgent && conversation?.agent_id != null ? agentsMap?.[conversation.agent_id] : undefined; - const getGreeting = useCallback(() => { - if (typeof startupConfig?.interface?.customWelcome === 'string') { - const customWelcome = startupConfig.interface.customWelcome; - // Replace {{user.name}} with actual user name if available - if (user?.name && customWelcome.includes('{{user.name}}')) { - return customWelcome.replace(/{{user.name}}/g, user.name); - } - return customWelcome; - } + const customWelcome = + typeof startupConfig?.interface?.customWelcome === 'string' + ? startupConfig.interface.customWelcome + : undefined; - const now = new Date(); - const hours = now.getHours(); - - const dayOfWeek = now.getDay(); - const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; - - // Early morning (midnight to 4:59 AM) - if (hours >= 0 && hours < 5) { - return localize('com_ui_late_night'); - } - // Morning (6 AM to 11:59 AM) - else if (hours < 12) { - if (isWeekend) { - return localize('com_ui_weekend_morning'); - } - return localize('com_ui_good_morning'); - } - // Afternoon (12 PM to 4:59 PM) - else if (hours < 17) { - return localize('com_ui_good_afternoon'); - } - // Evening (5 PM to 8:59 PM) - else { - return localize('com_ui_good_evening'); - } - }, [localize, startupConfig?.interface?.customWelcome, user?.name]); + const scheduledGreeting = useGreeting(user?.name); const handleLineCountChange = useCallback((count: number) => { setTextHasMultipleLines(count > 1); @@ -165,10 +135,12 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding: return margin; }, [lineCount, description, textHasMultipleLines, contentHeight]); - const greetingText = - typeof startupConfig?.interface?.customWelcome === 'string' - ? getGreeting() - : getGreeting() + (user?.name ? ', ' + user.name : ''); + const resolvedWelcome = + customWelcome != null && user?.name + ? customWelcome.replace(/{{user.name}}/g, user.name) + : customWelcome; + + const greetingText = resolvedWelcome ?? scheduledGreeting; return (
({ jest.mock('~/hooks', () => ({ useAuthContext: () => ({ user: undefined }), + useGreeting: () => 'Welcome', useLocalize: () => (key: string) => { const translations: Record = { com_agents_contact: 'Contact', com_agents_no_contact_available: 'No contact available', - com_ui_good_morning: 'Good morning', - com_ui_good_afternoon: 'Good afternoon', - com_ui_good_evening: 'Good evening', - com_ui_late_night: 'Good evening', - com_ui_weekend_morning: 'Good morning', }; return translations[key] || key; }, diff --git a/client/src/hooks/__tests__/useGreeting.test.tsx b/client/src/hooks/__tests__/useGreeting.test.tsx new file mode 100644 index 0000000000..647b2aaad8 --- /dev/null +++ b/client/src/hooks/__tests__/useGreeting.test.tsx @@ -0,0 +1,112 @@ +import { act, renderHook } from '@testing-library/react'; +import type { TranslationKeys } from '../useLocalize'; +import useGreeting from '../useGreeting'; + +jest.mock('../useLocalize', () => { + const mockTranslations: Record = jest.requireActual( + '~/locales/en/translation.json', + ); + return { + __esModule: true, + default: () => (key: TranslationKeys, options?: { name?: string }) => + mockTranslations[key].replace(/{{name}}/g, options?.name ?? ''), + }; +}); + +/** 2024-01-09 is a Tuesday, which uses the default schedule. */ +const tuesdayAt = (hours: number, minutes = 0) => new Date(2024, 0, 9, hours, minutes, 0, 0); + +describe('useGreeting', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('resolves a localized greeting naming the user after mount', () => { + jest.setSystemTime(tuesdayAt(9)); + const { result } = renderHook(() => useGreeting('Test User')); + expect(result.current).toContain('Test User'); + }); + + it('omits the name when the user has none', () => { + jest.setSystemTime(tuesdayAt(9)); + const { result } = renderHook(() => useGreeting()); + expect(result.current).not.toBe(''); + expect(result.current).not.toContain('undefined'); + expect(result.current).not.toMatch(/,\s*$/); + }); + + it('replaces the fallback once the mount effect resolves local time', () => { + jest.setSystemTime(tuesdayAt(9)); + const { result } = renderHook(() => useGreeting('Test User', 'fallback')); + expect(result.current).not.toBe('fallback'); + }); + + it('updates automatically across every slot of the day', () => { + jest.setSystemTime(tuesdayAt(0, 0)); + const { result } = renderHook(() => useGreeting('Test User')); + + const seen = [result.current]; + for (let hour = 1; hour < 24; hour++) { + act(() => { + jest.advanceTimersByTime(60 * 60 * 1000); + }); + seen.push(result.current); + } + + seen.forEach((greeting) => expect(greeting).toContain('Test User')); + /** Six slots a day: late night, dawn, morning, afternoon, evening, late night. */ + expect(new Set(seen).size).toBeGreaterThanOrEqual(4); + }); + + it('recalculates when the tab becomes visible again after a clock jump', () => { + jest.setSystemTime(tuesdayAt(11, 0)); + const { result } = renderHook(() => useGreeting('Test User')); + const morning = result.current; + + /** Simulates a sleep/timezone shift: wall clock moved without the timer firing. */ + jest.setSystemTime(tuesdayAt(18, 0)); + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + expect(result.current).not.toBe(morning); + expect(result.current).toContain('Test User'); + expect(jest.getTimerCount()).toBe(1); + }); + + it('ignores visibility changes while the tab is hidden', () => { + jest.setSystemTime(tuesdayAt(11, 0)); + const { result } = renderHook(() => useGreeting('Test User')); + const morning = result.current; + + const visibilityState = jest + .spyOn(document, 'visibilityState', 'get') + .mockReturnValue('hidden'); + jest.setSystemTime(tuesdayAt(18, 0)); + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + expect(result.current).toBe(morning); + visibilityState.mockRestore(); + }); + + it('clears its timer and listeners on unmount', () => { + jest.setSystemTime(tuesdayAt(11, 59)); + const removeDocumentListener = jest.spyOn(document, 'removeEventListener'); + const removeWindowListener = jest.spyOn(window, 'removeEventListener'); + + const { unmount } = renderHook(() => useGreeting('Test User')); + expect(jest.getTimerCount()).toBe(1); + + unmount(); + expect(jest.getTimerCount()).toBe(0); + expect(removeDocumentListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); + expect(removeWindowListener).toHaveBeenCalledWith('focus', expect.any(Function)); + + removeDocumentListener.mockRestore(); + removeWindowListener.mockRestore(); + }); +}); diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts index b24e3a6aac..f339fc5f81 100644 --- a/client/src/hooks/index.ts +++ b/client/src/hooks/index.ts @@ -27,6 +27,7 @@ export type { TranslationKeys } from './useLocalize'; export { default as useTimeout } from './useTimeout'; export { default as useNewConvo } from './useNewConvo'; export { default as useLocalize } from './useLocalize'; +export { default as useGreeting } from './useGreeting'; export { default as useFocusTrap } from './useFocusTrap'; export { default as useFavorites } from './useFavorites'; export { default as useToolFavorites } from './useToolFavorites'; diff --git a/client/src/hooks/useGreeting.ts b/client/src/hooks/useGreeting.ts new file mode 100644 index 0000000000..d1cdbfb993 --- /dev/null +++ b/client/src/hooks/useGreeting.ts @@ -0,0 +1,50 @@ +import { useState, useEffect } from 'react'; +import type { TranslationKeys } from './useLocalize'; +import { getGreetingKey, getMsUntilNextGreeting } from '~/utils/greeting'; +import useLocalize from './useLocalize'; + +/** + * Returns the localized, schedule-based greeting for the user's local time. The key is + * resolved only after mount so server-rendered markup matches the first client render. + * A single timer is armed for the next slot boundary, and the key is recalculated when + * the tab becomes visible again in case the clock or timezone moved while it was hidden. + */ +export default function useGreeting(name?: string, fallback = ''): string { + const localize = useLocalize(); + const [greetingKey, setGreetingKey] = useState(null); + + const hasName = Boolean(name); + + useEffect(() => { + let timeoutId: ReturnType; + + const update = () => { + clearTimeout(timeoutId); + const now = new Date(); + setGreetingKey(getGreetingKey(now, hasName)); + timeoutId = setTimeout(update, Math.max(getMsUntilNextGreeting(now), 1000)); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + update(); + } + }; + + update(); + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('focus', update); + + return () => { + clearTimeout(timeoutId); + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('focus', update); + }; + }, [hasName]); + + if (greetingKey == null) { + return fallback; + } + + return localize(greetingKey, { name }); +} diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index ff5dc207b5..2b06395da8 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1270,9 +1270,54 @@ "com_ui_getting_started": "Getting Started", "com_ui_go_back": "Go back", "com_ui_go_to_conversation": "Go to conversation", - "com_ui_good_afternoon": "Good afternoon", - "com_ui_good_evening": "Good evening", - "com_ui_good_morning": "Good morning", + "com_ui_greeting_back_at_it": "Back at it", + "com_ui_greeting_back_at_it_named": "Back at it, {{name}}", + "com_ui_greeting_before_sun": "Up before the sun", + "com_ui_greeting_before_sun_named": "Up before the sun, {{name}}?", + "com_ui_greeting_bird_or_owl": "Early bird or night owl?", + "com_ui_greeting_bird_or_owl_named": "Early bird or night owl, {{name}}?", + "com_ui_greeting_coffee": "Coffee and a plan?", + "com_ui_greeting_coffee_named": "Coffee and a plan, {{name}}?", + "com_ui_greeting_cooking": "What's cooking?", + "com_ui_greeting_cooking_named": "What's cooking, {{name}}?", + "com_ui_greeting_day_going": "How's the day going?", + "com_ui_greeting_day_going_named": "How's the day going, {{name}}?", + "com_ui_greeting_early_bird": "Hey, early bird", + "com_ui_greeting_early_bird_named": "Up early, {{name}}?", + "com_ui_greeting_evening_shift": "The evening shift begins", + "com_ui_greeting_evening_shift_named": "Evening shift, {{name}}?", + "com_ui_greeting_first_move": "What's the first move?", + "com_ui_greeting_first_move_named": "What's the first move, {{name}}?", + "com_ui_greeting_good_afternoon": "Good afternoon", + "com_ui_greeting_good_afternoon_named": "Good afternoon, {{name}}", + "com_ui_greeting_good_evening": "Good evening", + "com_ui_greeting_good_evening_named": "Good evening, {{name}}", + "com_ui_greeting_good_morning": "Good morning", + "com_ui_greeting_good_morning_named": "Good morning, {{name}}", + "com_ui_greeting_happy_thursday": "Happy Thursday", + "com_ui_greeting_happy_thursday_named": "Happy Thursday, {{name}}", + "com_ui_greeting_new_week": "New week, fresh page", + "com_ui_greeting_new_week_named": "New week, fresh page, {{name}}", + "com_ui_greeting_ready": "Ready when you are", + "com_ui_greeting_ready_named": "Ready when you are, {{name}}", + "com_ui_greeting_returns": "Welcome back!", + "com_ui_greeting_returns_named": "Welcome back, {{name}}!", + "com_ui_greeting_still_up": "Still up?", + "com_ui_greeting_still_up_named": "Still up, {{name}}?", + "com_ui_greeting_tackle": "What are we tackling?", + "com_ui_greeting_tackle_named": "What are we tackling, {{name}}?", + "com_ui_greeting_think_through": "What shall we think through?", + "com_ui_greeting_think_through_named": "What shall we think through, {{name}}?", + "com_ui_greeting_up_already": "Still up, or up already?", + "com_ui_greeting_up_already_named": "Still up, or up already, {{name}}?", + "com_ui_greeting_up_late": "Up late?", + "com_ui_greeting_up_late_named": "Up late, {{name}}?", + "com_ui_greeting_whats_left": "What's left on the list?", + "com_ui_greeting_whats_left_named": "What's left on the list, {{name}}?", + "com_ui_greeting_winding_down": "Winding down?", + "com_ui_greeting_winding_down_named": "Winding down, {{name}}?", + "com_ui_greeting_working_on": "What are we working on?", + "com_ui_greeting_working_on_named": "What are we working on, {{name}}?", "com_ui_group": "Group", "com_ui_handoff_instructions": "Handoff instructions", "com_ui_happy_birthday": "It's my 1st birthday!", @@ -1348,7 +1393,6 @@ "com_ui_langfuse_test_unexpected_response": "Langfuse returned an unexpected response", "com_ui_langfuse_testing": "Testing connection", "com_ui_langfuse_title": "Langfuse connection", - "com_ui_late_night": "Happy late night", "com_ui_latest": "latest", "com_ui_latest_activity": "Latest activity", "com_ui_latest_footer": "Every AI for Everyone.", @@ -2143,7 +2187,6 @@ "com_ui_web_searched": "Searched the web", "com_ui_web_searching": "Searching the web", "com_ui_web_searching_again": "Searching the web again", - "com_ui_weekend_morning": "Happy weekend", "com_ui_write": "Writing", "com_ui_writing_command": "Writing command", "com_ui_x_selected": "{{0}} selected", diff --git a/client/src/utils/__tests__/greeting.test.ts b/client/src/utils/__tests__/greeting.test.ts new file mode 100644 index 0000000000..ef2c4d83cc --- /dev/null +++ b/client/src/utils/__tests__/greeting.test.ts @@ -0,0 +1,167 @@ +import type { GreetingSlot } from '../greeting'; +import { + dayKeys, + getGreetingKey, + getGreetingSlot, + getGreetingOption, + greetingSlotsByDay, + defaultGreetingSlots, + getMsUntilNextGreeting, +} from '../greeting'; +import translationEn from '~/locales/en/translation.json'; + +/** 2024-01-07 is a Sunday, so index 0..6 maps directly onto sun..sat. */ +const dateForDay = (dayIndex: number, hours: number, minutes = 0, seconds = 0) => + new Date(2024, 0, 7 + dayIndex, hours, minutes, seconds, 0); + +const slotsFor = (dayIndex: number) => + greetingSlotsByDay[dayKeys[dayIndex]] ?? defaultGreetingSlots; + +const allSlots: GreetingSlot[] = [ + ...defaultGreetingSlots, + ...Object.values(greetingSlotsByDay).flatMap((slots) => slots ?? []), +]; + +const textFor = (key: string) => translationEn[key as keyof typeof translationEn]; + +describe('greeting schedule', () => { + it('references translation keys that exist in the English catalog', () => { + allSlots.forEach((slot) => { + slot.options.forEach((option) => { + expect(translationEn).toHaveProperty(option.key); + expect(translationEn).toHaveProperty(option.namedKey); + }); + }); + }); + + /** Every variant must greet a signed-in user by name, at every hour of every day. */ + it('pairs every variant with a personalized form', () => { + allSlots.forEach((slot) => { + expect(slot.options.length).toBeGreaterThan(0); + slot.options.forEach((option) => { + expect(textFor(option.namedKey)).toContain('{{name}}'); + expect(textFor(option.key)).not.toContain('{{name}}'); + }); + }); + }); + + /** Matches the 56-character cutoff in Landing's getTextSizeClass. */ + it('keeps every variant within the landing large-text budget for a long name', () => { + allSlots.forEach((slot) => { + slot.options.forEach((option) => { + const rendered = textFor(option.namedKey).replace('{{name}}', 'Alexandra Kowalski'); + expect(rendered.length).toBeLessThan(56); + }); + }); + }); +}); + +describe('getGreetingSlot', () => { + it('maps each weekday to its own schedule', () => { + dayKeys.forEach((_key, index) => { + expect(dateForDay(index, 12).getDay()).toBe(index); + expect(getGreetingSlot(dateForDay(index, 12))).toBe(slotsFor(index)[3]); + }); + }); + + it('falls back to the default schedule on Tuesday', () => { + expect(greetingSlotsByDay.tue).toBeUndefined(); + expect(dateForDay(2, 9).getDay()).toBe(2); + defaultGreetingSlots.forEach((slot, index) => { + const hour = index === 0 ? 0 : defaultGreetingSlots[index - 1].until; + expect(getGreetingSlot(dateForDay(2, hour))).toBe(slot); + }); + }); + + it('selects the first slot whose `until` exceeds the hour, for every boundary', () => { + dayKeys.forEach((_key, dayIndex) => { + const slots = slotsFor(dayIndex); + slots.forEach((slot, slotIndex) => { + const start = slotIndex === 0 ? 0 : slots[slotIndex - 1].until; + expect(getGreetingSlot(dateForDay(dayIndex, start))).toBe(slot); + expect(getGreetingSlot(dateForDay(dayIndex, slot.until - 1, 59, 59))).toBe(slot); + }); + }); + }); + + it.each([ + [0, 0], + [3, 0], + [4, 1], + [5, 1], + [6, 1], + [7, 2], + [11, 2], + [12, 3], + [16, 3], + [17, 4], + [21, 4], + [22, 5], + [23, 5], + ])('resolves hour %i to slot index %i on every day', (hour, slotIndex) => { + dayKeys.forEach((_key, dayIndex) => { + expect(getGreetingSlot(dateForDay(dayIndex, hour))).toBe(slotsFor(dayIndex)[slotIndex]); + }); + }); +}); + +describe('getGreetingOption', () => { + it('holds the same variant for every hour within a slot', () => { + dayKeys.forEach((_key, dayIndex) => { + const slots = slotsFor(dayIndex); + slots.forEach((slot, slotIndex) => { + const start = slotIndex === 0 ? 0 : slots[slotIndex - 1].until; + const expected = getGreetingOption(dateForDay(dayIndex, start)); + for (let hour = start; hour < slot.until; hour++) { + expect(getGreetingOption(dateForDay(dayIndex, hour, 30))).toBe(expected); + } + }); + }); + }); + + it('rotates the variant from one day to the next', () => { + const noon = (dayOffset: number) => new Date(2024, 0, 7 + dayOffset, 12, 0, 0, 0); + const firstThree = [0, 7, 14].map((offset) => getGreetingOption(noon(offset)).key); + expect(new Set(firstThree).size).toBeGreaterThan(1); + }); + + it('picks a variant that belongs to the active slot', () => { + dayKeys.forEach((_key, dayIndex) => { + for (let hour = 0; hour < 24; hour++) { + const date = dateForDay(dayIndex, hour); + expect(getGreetingSlot(date).options).toContain(getGreetingOption(date)); + } + }); + }); +}); + +describe('getGreetingKey', () => { + it('uses the personalized key only when a name is available', () => { + const date = dateForDay(3, 20); + const option = getGreetingOption(date); + expect(getGreetingKey(date, true)).toBe(option.namedKey); + expect(getGreetingKey(date, false)).toBe(option.key); + }); + + it('resolves a personalized key at every hour of every day', () => { + dayKeys.forEach((_key, dayIndex) => { + for (let hour = 0; hour < 24; hour++) { + expect(textFor(getGreetingKey(dateForDay(dayIndex, hour), true))).toContain('{{name}}'); + } + }); + }); +}); + +describe('getMsUntilNextGreeting', () => { + it('counts down to the end of the active slot', () => { + expect(getMsUntilNextGreeting(dateForDay(2, 3, 59, 0))).toBe(60 * 1000); + expect(getMsUntilNextGreeting(dateForDay(2, 6, 59, 0))).toBe(60 * 1000); + expect(getMsUntilNextGreeting(dateForDay(2, 11, 30, 0))).toBe(30 * 60 * 1000); + expect(getMsUntilNextGreeting(dateForDay(2, 16, 0, 0))).toBe(60 * 60 * 1000); + expect(getMsUntilNextGreeting(dateForDay(2, 21, 0, 0))).toBe(60 * 60 * 1000); + }); + + it('rolls over to local midnight for the final slot', () => { + expect(getMsUntilNextGreeting(dateForDay(2, 23, 0, 0))).toBe(60 * 60 * 1000); + }); +}); diff --git a/client/src/utils/greeting.ts b/client/src/utils/greeting.ts new file mode 100644 index 0000000000..a2d4bd8eb0 --- /dev/null +++ b/client/src/utils/greeting.ts @@ -0,0 +1,201 @@ +import type { TranslationKeys } from '~/hooks/useLocalize'; + +export type GreetingOption = { + key: TranslationKeys; + namedKey: TranslationKeys; +}; + +export type GreetingSlot = { + until: number; + options: GreetingOption[]; +}; + +export type DayKey = 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat'; + +export const dayKeys: DayKey[] = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; + +const lateNight: GreetingOption[] = [ + { key: 'com_ui_greeting_up_late', namedKey: 'com_ui_greeting_up_late_named' }, + { key: 'com_ui_greeting_still_up', namedKey: 'com_ui_greeting_still_up_named' }, + { key: 'com_ui_greeting_think_through', namedKey: 'com_ui_greeting_think_through_named' }, +]; + +/** 04:00 to 07:00, where the visitor could be up early or not yet in bed. */ +const dawn: GreetingOption[] = [ + { key: 'com_ui_greeting_bird_or_owl', namedKey: 'com_ui_greeting_bird_or_owl_named' }, + { key: 'com_ui_greeting_up_already', namedKey: 'com_ui_greeting_up_already_named' }, + { key: 'com_ui_greeting_before_sun', namedKey: 'com_ui_greeting_before_sun_named' }, + { key: 'com_ui_greeting_early_bird', namedKey: 'com_ui_greeting_early_bird_named' }, +]; + +const morning: GreetingOption[] = [ + { key: 'com_ui_greeting_good_morning', namedKey: 'com_ui_greeting_good_morning_named' }, + { key: 'com_ui_greeting_first_move', namedKey: 'com_ui_greeting_first_move_named' }, + { key: 'com_ui_greeting_ready', namedKey: 'com_ui_greeting_ready_named' }, +]; + +const afternoon: GreetingOption[] = [ + { key: 'com_ui_greeting_good_afternoon', namedKey: 'com_ui_greeting_good_afternoon_named' }, + { key: 'com_ui_greeting_day_going', namedKey: 'com_ui_greeting_day_going_named' }, + { key: 'com_ui_greeting_working_on', namedKey: 'com_ui_greeting_working_on_named' }, +]; + +const evening: GreetingOption[] = [ + { key: 'com_ui_greeting_good_evening', namedKey: 'com_ui_greeting_good_evening_named' }, + { key: 'com_ui_greeting_winding_down', namedKey: 'com_ui_greeting_winding_down_named' }, + { key: 'com_ui_greeting_whats_left', namedKey: 'com_ui_greeting_whats_left_named' }, +]; + +const cooking: GreetingOption = { + key: 'com_ui_greeting_cooking', + namedKey: 'com_ui_greeting_cooking_named', +}; + +const welcomeBack: GreetingOption = { + key: 'com_ui_greeting_returns', + namedKey: 'com_ui_greeting_returns_named', +}; + +const newWeek: GreetingOption = { + key: 'com_ui_greeting_new_week', + namedKey: 'com_ui_greeting_new_week_named', +}; + +const backAtIt: GreetingOption = { + key: 'com_ui_greeting_back_at_it', + namedKey: 'com_ui_greeting_back_at_it_named', +}; + +const eveningShift: GreetingOption = { + key: 'com_ui_greeting_evening_shift', + namedKey: 'com_ui_greeting_evening_shift_named', +}; + +const happyThursday: GreetingOption = { + key: 'com_ui_greeting_happy_thursday', + namedKey: 'com_ui_greeting_happy_thursday_named', +}; + +const coffee: GreetingOption = { + key: 'com_ui_greeting_coffee', + namedKey: 'com_ui_greeting_coffee_named', +}; + +const tackle: GreetingOption = { + key: 'com_ui_greeting_tackle', + namedKey: 'com_ui_greeting_tackle_named', +}; + +export const defaultGreetingSlots: GreetingSlot[] = [ + { until: 4, options: lateNight }, + { until: 7, options: dawn }, + { until: 12, options: morning }, + { until: 17, options: afternoon }, + { until: 22, options: evening }, + { until: 24, options: lateNight }, +]; + +export const greetingSlotsByDay: Partial> = { + sun: [ + { until: 4, options: lateNight }, + { until: 7, options: dawn }, + { until: 12, options: [...morning, cooking] }, + { until: 17, options: afternoon }, + { until: 22, options: [...evening, welcomeBack] }, + { until: 24, options: lateNight }, + ], + + mon: [ + { until: 4, options: lateNight }, + { until: 7, options: dawn }, + { until: 12, options: [...morning, newWeek] }, + { until: 17, options: [...afternoon, backAtIt] }, + { until: 22, options: [...evening, eveningShift] }, + { until: 24, options: lateNight }, + ], + + wed: [ + { until: 4, options: lateNight }, + { until: 7, options: dawn }, + { until: 12, options: morning }, + { until: 17, options: afternoon }, + { until: 22, options: [...evening, welcomeBack] }, + { until: 24, options: lateNight }, + ], + + thu: [ + { until: 4, options: lateNight }, + { until: 7, options: dawn }, + { until: 12, options: [...morning, happyThursday] }, + { until: 17, options: afternoon }, + { until: 22, options: evening }, + { until: 24, options: lateNight }, + ], + + fri: [ + { until: 4, options: lateNight }, + { until: 7, options: dawn }, + { until: 12, options: morning }, + { until: 17, options: afternoon }, + { until: 22, options: [...evening, eveningShift] }, + { until: 24, options: lateNight }, + ], + + sat: [ + { until: 4, options: lateNight }, + { until: 7, options: dawn }, + { until: 12, options: [...morning, coffee] }, + { until: 17, options: [...afternoon, tackle] }, + { until: 22, options: evening }, + { until: 24, options: lateNight }, + ], +}; + +const getSlots = (date: Date): GreetingSlot[] => + greetingSlotsByDay[dayKeys[date.getDay()]] ?? defaultGreetingSlots; + +const getSlotIndex = (slots: GreetingSlot[], hours: number): number => { + const index = slots.findIndex((slot) => hours < slot.until); + return index === -1 ? slots.length - 1 : index; +}; + +/** Local calendar day as a whole number, so a slot's variant holds for the whole day. */ +const getDayNumber = (date: Date): number => + Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000); + +/** Slot for the given local date/time, from the day's schedule or the default one. */ +export const getGreetingSlot = (date: Date = new Date()): GreetingSlot => { + const slots = getSlots(date); + return slots[getSlotIndex(slots, date.getHours())]; +}; + +/** + * Variant for the given local date/time. The choice rotates by calendar day, so the + * greeting holds steady across a slot but differs from one day to the next. + */ +export const getGreetingOption = (date: Date = new Date()): GreetingOption => { + const slots = getSlots(date); + const slotIndex = getSlotIndex(slots, date.getHours()); + const { options } = slots[slotIndex]; + return options[(getDayNumber(date) + slotIndex) % options.length]; +}; + +/** Translation key for the greeting, preferring the personalized variant when named. */ +export const getGreetingKey = (date: Date = new Date(), hasName = false): TranslationKeys => { + const option = getGreetingOption(date); + return hasName ? option.namedKey : option.key; +}; + +/** Milliseconds from `date` until the current greeting slot expires (local time). */ +export const getMsUntilNextGreeting = (date: Date = new Date()): number => { + const boundary = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + getGreetingSlot(date).until, + 0, + 0, + 0, + ); + return boundary.getTime() - date.getTime(); +}; diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index 9ca96fc7fb..b7877117a5 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -9,6 +9,7 @@ export * from './icons'; export * from './email'; export * from './share'; export * from './files'; +export * from './greeting'; export * from './latex'; export * from './tilde'; export * from './forms';