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:
2026-05-12 15:48:51 +02:00
parent 04a594963f
commit 0ab7055cf1
9 changed files with 395 additions and 217 deletions

View File

@@ -131,7 +131,9 @@ function buildDiffLine(item: ActivityItem): string | null {
if (keys.length === 0) return null;
return keys
.slice(0, 2)
.map((k) => `${humanizeFieldName(k)}: ${shortValue(oldObj[k], k)}${shortValue(newObj[k], k)}`)
.map(
(k) => `${humanizeFieldName(k)}: ${shortValue(oldObj[k], k)}${shortValue(newObj[k], k)}`,
)
.join(' · ');
}

View File

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

View File

@@ -0,0 +1,142 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Clock, X } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { formatTimezoneLabel } from '@/lib/i18n/timezones';
const DISMISS_STORAGE_KEY = 'pn-crm.tz-drift.dismissed';
interface MeResponse {
data: {
profile?: {
timezone?: string | null;
preferences?: { timezone?: string | null } | null;
};
};
}
/**
* Detects when the user's machine timezone (via Intl) doesn't match the
* timezone stored on their CRM profile and surfaces a small banner offering
* a one-tap update.
*
* Why a banner (not auto-update): the stored timezone is what the CRM uses
* to render due-dates, send daily-digest emails, and schedule reminders.
* Silently re-pinning it when a rep crosses a timezone for a single day
* would change their reminder firing time underfoot and surprise them. The
* banner gives them control: keep the home zone, or commit to the new one.
*
* Dismissal is sticky per browser via localStorage so the rep isn't nagged
* once they've decided. Clearing storage or signing in elsewhere re-asks.
*/
export function TimezoneDriftBanner() {
const queryClient = useQueryClient();
const [detected, setDetected] = useState<string | null>(null);
const [stored, setStored] = useState<string | null>(null);
const [profileLoaded, setProfileLoaded] = useState(false);
const [dismissed, setDismissed] = useState(false);
// Read on mount: browser's resolved timezone (mirrors the OS setting) +
// the user's stored preference + any prior dismissal flag. All three
// are stable across the lifetime of the dashboard view; the banner
// makes a single comparison and either renders or doesn't.
useEffect(() => {
try {
setDetected(Intl.DateTimeFormat().resolvedOptions().timeZone || null);
} catch {
setDetected(null);
}
try {
const flag = window.localStorage.getItem(DISMISS_STORAGE_KEY);
if (flag === 'true') setDismissed(true);
} catch {
// Private mode or quota — proceed without dismissal memory.
}
void apiFetch<MeResponse>('/api/v1/me')
.then((res) => {
const tz =
res.data.profile?.preferences?.timezone ?? res.data.profile?.timezone ?? null;
setStored(tz);
})
.catch(() => setStored(null))
.finally(() => setProfileLoaded(true));
}, []);
const mutation = useMutation({
mutationFn: async (newTz: string) => {
await apiFetch('/api/v1/users/me/preferences', {
method: 'PATCH',
body: { timezone: newTz },
});
},
onSuccess: () => {
toast.success(`Timezone updated to ${formatTimezoneLabel(detected ?? '')}.`);
queryClient.invalidateQueries({ queryKey: ['me'] });
setStored(detected);
},
onError: (err) => toastError(err),
});
function dismiss() {
setDismissed(true);
try {
window.localStorage.setItem(DISMISS_STORAGE_KEY, 'true');
} catch {
// Non-fatal — we just don't persist the dismissal.
}
}
// Render gates: don't show until the profile fetch resolves (avoid
// flashing the banner against a stale `stored=null`); don't show when
// the detected and stored zones agree or both are missing; don't show
// when the rep has dismissed it.
if (!profileLoaded) return null;
if (dismissed) return null;
if (!detected) return null;
if (!stored) return null;
if (detected === stored) return null;
return (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-amber-300 bg-amber-50 px-4 py-2.5 text-sm text-amber-900 sm:flex-nowrap">
<div className="flex items-start gap-2 min-w-0">
<Clock className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium leading-tight">
You appear to be in{' '}
<span className="font-semibold">{formatTimezoneLabel(detected)}</span>.
</p>
<p className="text-xs leading-snug text-amber-900/80">
Your CRM is set to <span className="font-medium">{formatTimezoneLabel(stored)}</span>.
Reminders and the daily digest follow the CRM setting.
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
type="button"
variant="outline"
size="sm"
className="border-amber-300 bg-white hover:bg-amber-100"
onClick={() => mutation.mutate(detected)}
disabled={mutation.isPending}
>
Update to {detected.split('/').pop()?.replace(/_/g, ' ')}
</Button>
<button
type="button"
onClick={dismiss}
aria-label="Dismiss timezone reminder"
className="rounded-full p-1 text-amber-700 hover:bg-amber-100"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}