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';