👋 feat: Day-Aware Landing Greeting Schedule (#14633)

* 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.
This commit is contained in:
Marco Beretta 2026-08-07 06:05:30 +02:00 committed by GitHub
parent bd5c1ad05c
commit e3b8e30327
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 594 additions and 51 deletions

View file

@ -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 (
<div

View file

@ -43,15 +43,11 @@ jest.mock('~/data-provider', () => ({
jest.mock('~/hooks', () => ({
useAuthContext: () => ({ user: undefined }),
useGreeting: () => 'Welcome',
useLocalize: () => (key: string) => {
const translations: Record<string, string> = {
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;
},

View file

@ -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<string, string> = 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();
});
});

View file

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

View file

@ -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<TranslationKeys | null>(null);
const hasName = Boolean(name);
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
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 });
}

View file

@ -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",

View file

@ -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);
});
});

View file

@ -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<Record<DayKey, GreetingSlot[]>> = {
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();
};

View file

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