From e3b8e30327d7db35986d860ae5f1e7fd3d5c7fef Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:05:30 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=91=8B=20feat:=20Day-Aware=20Landing=20Gr?= =?UTF-8?q?eeting=20Schedule=20(#14633)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: day-aware landing greeting schedule Replace the branching time-of-day greeting in Landing with a declarative schedule keyed by weekday and hour. Each slot maps to a translation key, with an optional personalized variant interpolating the user's name, and days without a custom schedule fall back to the default one. The greeting resolves after mount to keep server-rendered markup stable, arms a single timer for the next slot boundary instead of polling, and recalculates on tab visibility and window focus so a sleeping machine or timezone change does not leave a stale greeting on screen. * feat: rotate landing greeting variants by day Each schedule slot now holds a pool of variants instead of one line, and the active variant is chosen from the local calendar day, so the greeting holds steady across a slot but differs from one day to the next. Day-specific lines join their day's pool rather than replacing it. Raise the landing large-text cutoff to 56 characters so a personalized greeting with a long display name, or a longer translation of one, keeps the intended size, and add a test pinning every variant under that budget. * feat: add a dawn greeting slot between late night and morning 04:00 to 07:00 sits between the two moods the schedule had: too late for "up late", too early for "good morning", and the visitor could be up early or not yet in bed. Give it its own slot that plays on the ambiguity, and move the early bird line into it, where the timing actually fits. --- client/src/components/Chat/Landing.tsx | 54 ++--- .../__tests__/Landing.agent-contact.spec.tsx | 6 +- .../src/hooks/__tests__/useGreeting.test.tsx | 112 ++++++++++ client/src/hooks/index.ts | 1 + client/src/hooks/useGreeting.ts | 50 +++++ client/src/locales/en/translation.json | 53 ++++- client/src/utils/__tests__/greeting.test.ts | 167 +++++++++++++++ client/src/utils/greeting.ts | 201 ++++++++++++++++++ client/src/utils/index.ts | 1 + 9 files changed, 594 insertions(+), 51 deletions(-) create mode 100644 client/src/hooks/__tests__/useGreeting.test.tsx create mode 100644 client/src/hooks/useGreeting.ts create mode 100644 client/src/utils/__tests__/greeting.test.ts create mode 100644 client/src/utils/greeting.ts 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';