feat(dashboard): local-time greeting + timezone-drift banner
Greeting - The "Good morning / afternoon / evening, Matt" line now derives from the browser's local time, computed inside a useEffect so the rendered HTML can't lock to the server's clock during hydration. Until the effect fires, the header reads "Welcome" — a neutral phrase that's correct at every hour and never produces a hydration warning. The phrase re-evaluates hourly so a rep leaving the dashboard open across a boundary (5am, noon, 6pm) doesn't keep stale text on screen. Timezone-drift banner - New <TimezoneDriftBanner> on the dashboard surfaces when the browser's resolved timezone (Intl.DateTimeFormat().resolvedOptions().timeZone, which follows the OS — and the OS usually follows physical location) doesn't match the user's stored CRM preference. The rep gets a one-tap "Update to Tokyo" button and a dismiss × that's sticky per browser via localStorage. - Why a banner rather than auto-update: the stored timezone drives reminder firing time, daily-digest delivery, and due-date rendering. Silently pinning it to a transient travel location would shift their reminder schedule underfoot. The banner gives them control. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
@@ -9,6 +9,7 @@ import { apiFetch } from '@/lib/api/client';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { CustomizeWidgetsMenu } from './customize-widgets-menu';
|
||||
import { DateRangePicker } from './date-range-picker';
|
||||
import { TimezoneDriftBanner } from './timezone-drift-banner';
|
||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||
import type { DashboardWidget } from './widget-registry';
|
||||
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||
@@ -45,6 +46,12 @@ interface MeData {
|
||||
}
|
||||
|
||||
function timeOfDayGreeting(): string {
|
||||
// new Date().getHours() uses the browser's local timezone, which the OS
|
||||
// updates automatically on most modern devices when the user travels (laptop
|
||||
// "set timezone automatically" on macOS, phones via cell-tower / GPS). So
|
||||
// this reflects the user's physical location as long as their machine clock
|
||||
// does. We still defer the compute to a useEffect on mount so the rendered
|
||||
// HTML can never disagree with the server's clock during hydration.
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 5) return 'Up late';
|
||||
if (hour < 12) return 'Good morning';
|
||||
@@ -73,9 +80,27 @@ export function DashboardShell() {
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const firstName = me.data?.data?.profile?.firstName?.trim();
|
||||
// Time-aware greeting line, falls back to a generic "Welcome back" when
|
||||
// we don't know the user's first name yet (e.g. profile not filled out).
|
||||
const greeting = firstName ? `${timeOfDayGreeting()}, ${firstName}` : 'Welcome back';
|
||||
|
||||
// Greeting word is computed in a useEffect so the rendered HTML can't lock
|
||||
// to the server's clock during hydration. Until the effect fires, the
|
||||
// header reads "Welcome" — a neutral phrase that's correct at every hour
|
||||
// and never produces a hydration warning. `clientGreeting` flips to the
|
||||
// local-time-aware phrasing once the component has mounted.
|
||||
const [clientGreeting, setClientGreeting] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
setClientGreeting(timeOfDayGreeting());
|
||||
// Re-evaluate hourly so a rep who leaves the dashboard open through a
|
||||
// boundary (5am, noon, 6pm) doesn't keep stale text on screen.
|
||||
const interval = window.setInterval(
|
||||
() => setClientGreeting(timeOfDayGreeting()),
|
||||
60 * 60_000,
|
||||
);
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const greeting = firstName
|
||||
? `${clientGreeting ?? 'Welcome'}, ${firstName}`
|
||||
: (clientGreeting ?? 'Welcome back');
|
||||
|
||||
// Use a partial query-key prefix (no range segment) for invalidations.
|
||||
// Reading: "any cached analytics result, regardless of range, please
|
||||
@@ -108,6 +133,8 @@ export function DashboardShell() {
|
||||
<h1 className="mt-1 text-xl font-bold tracking-tight text-foreground">{greeting}</h1>
|
||||
</div>
|
||||
|
||||
<TimezoneDriftBanner />
|
||||
|
||||
<PageHeader
|
||||
title={greeting}
|
||||
eyebrow="Dashboard"
|
||||
|
||||
Reference in New Issue
Block a user