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

@@ -95,7 +95,12 @@ export function AdminSectionsBrowser({ portSlug, groups }: AdminSectionsBrowserP
</p> </p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filteredMatches.map((s) => ( {filteredMatches.map((s) => (
<SectionCard key={`${s.href}-${s.groupTitle}`} portSlug={portSlug} section={s} groupTitle={s.groupTitle} /> <SectionCard
key={`${s.href}-${s.groupTitle}`}
portSlug={portSlug}
section={s}
groupTitle={s.groupTitle}
/>
))} ))}
</div> </div>
</div> </div>
@@ -138,7 +143,11 @@ function SectionCard({
href={`/${portSlug}/admin/${section.href}` as any} href={`/${portSlug}/admin/${section.href}` as any}
className="block group" className="block group"
> >
<Card className={cn('h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30')}> <Card
className={cn(
'h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30',
)}
>
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2"> <CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
<Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" /> <Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" />
<div className="flex-1"> <div className="flex-1">

View File

@@ -368,7 +368,10 @@ function InterestLinkPicker({
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[320px] p-0" align="start"> <PopoverContent
className="w-[var(--radix-popper-anchor-width)] min-w-[320px] p-0"
align="start"
>
<Command> <Command>
<CommandInput placeholder="Search prospects…" /> <CommandInput placeholder="Search prospects…" />
<CommandList> <CommandList>
@@ -415,7 +418,9 @@ function InterestLinkPicker({
> >
{stageLabel(opt.pipelineStage)} {stageLabel(opt.pipelineStage)}
</span> </span>
{value === opt.id ? <Check className="h-3.5 w-3.5 text-muted-foreground" /> : null} {value === opt.id ? (
<Check className="h-3.5 w-3.5 text-muted-foreground" />
) : null}
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>

View File

@@ -400,8 +400,9 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
onChange={setAttachedYachtIds} onChange={setAttachedYachtIds}
/> />
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
Adding a yacht transfers its ownership to this company (logged in the yacht&apos;s Adding a yacht transfers its ownership to this company (logged in the
audit trail). Skip if you only want to associate without changing ownership. yacht&apos;s audit trail). Skip if you only want to associate without changing
ownership.
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -662,13 +663,19 @@ function EntityMultiPicker({
size="sm" size="sm"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className={cn('w-full justify-between font-normal', !selectedIds.length && 'text-muted-foreground')} className={cn(
'w-full justify-between font-normal',
!selectedIds.length && 'text-muted-foreground',
)}
> >
{placeholder} {placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[280px] p-0" align="start"> <PopoverContent
className="w-[var(--radix-popper-anchor-width)] min-w-[280px] p-0"
align="start"
>
<Command shouldFilter={false}> <Command shouldFilter={false}>
<CommandInput placeholder="Search…" onValueChange={setSearch} /> <CommandInput placeholder="Search…" onValueChange={setSearch} />
<CommandList> <CommandList>
@@ -683,10 +690,7 @@ function EntityMultiPicker({
onSelect={() => toggle(opt.value)} onSelect={() => toggle(opt.value)}
> >
<Check <Check
className={cn( className={cn('mr-2 h-4 w-4', isSelected ? 'opacity-100' : 'opacity-0')}
'mr-2 h-4 w-4',
isSelected ? 'opacity-100' : 'opacity-0',
)}
/> />
{opt.label} {opt.label}
</CommandItem> </CommandItem>

View File

@@ -131,7 +131,9 @@ function buildDiffLine(item: ActivityItem): string | null {
if (keys.length === 0) return null; if (keys.length === 0) return null;
return keys return keys
.slice(0, 2) .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(' · '); .join(' · ');
} }

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; 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 { PageHeader } from '@/components/shared/page-header';
import { CustomizeWidgetsMenu } from './customize-widgets-menu'; import { CustomizeWidgetsMenu } from './customize-widgets-menu';
import { DateRangePicker } from './date-range-picker'; import { DateRangePicker } from './date-range-picker';
import { TimezoneDriftBanner } from './timezone-drift-banner';
import { WidgetErrorBoundary } from './widget-error-boundary'; import { WidgetErrorBoundary } from './widget-error-boundary';
import type { DashboardWidget } from './widget-registry'; import type { DashboardWidget } from './widget-registry';
import { isCustomRange, type DateRange } from '@/lib/analytics/range'; import { isCustomRange, type DateRange } from '@/lib/analytics/range';
@@ -45,6 +46,12 @@ interface MeData {
} }
function timeOfDayGreeting(): string { 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(); const hour = new Date().getHours();
if (hour < 5) return 'Up late'; if (hour < 5) return 'Up late';
if (hour < 12) return 'Good morning'; if (hour < 12) return 'Good morning';
@@ -73,9 +80,27 @@ export function DashboardShell() {
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}); });
const firstName = me.data?.data?.profile?.firstName?.trim(); 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). // Greeting word is computed in a useEffect so the rendered HTML can't lock
const greeting = firstName ? `${timeOfDayGreeting()}, ${firstName}` : 'Welcome back'; // 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. // Use a partial query-key prefix (no range segment) for invalidations.
// Reading: "any cached analytics result, regardless of range, please // 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> <h1 className="mt-1 text-xl font-bold tracking-tight text-foreground">{greeting}</h1>
</div> </div>
<TimezoneDriftBanner />
<PageHeader <PageHeader
title={greeting} title={greeting}
eyebrow="Dashboard" 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>
);
}

View File

@@ -145,8 +145,7 @@ export function EoiGenerateDialog({
value: ctx.client.fullName, value: ctx.client.fullName,
present: !!ctx.client.fullName, present: !!ctx.client.fullName,
edit: { edit: {
onSave: async (next: string | null) => onSave: async (next: string | null) => await patchClient({ fullName: next ?? '' }),
await patchClient({ fullName: next ?? '' }),
placeholder: 'Full legal name', placeholder: 'Full legal name',
}, },
}, },
@@ -194,8 +193,7 @@ export function EoiGenerateDialog({
value: ctx.yacht?.name ?? null, value: ctx.yacht?.name ?? null,
edit: ctx.yacht edit: ctx.yacht
? { ? {
onSave: async (next: string | null) => onSave: async (next: string | null) => await patchYacht({ name: next ?? '' }),
await patchYacht({ name: next ?? '' }),
placeholder: 'Yacht name', placeholder: 'Yacht name',
} }
: undefined, : undefined,
@@ -323,12 +321,7 @@ export function EoiGenerateDialog({
</p> </p>
<dl className="space-y-1.5"> <dl className="space-y-1.5">
{optional.map((row) => ( {optional.map((row) => (
<PreviewRow <PreviewRow key={row.key} label={row.label} value={row.value} edit={row.edit} />
key={row.key}
label={row.label}
value={row.value}
edit={row.edit}
/>
))} ))}
</dl> </dl>
</div> </div>
@@ -477,9 +470,7 @@ function PreviewRow({
) )
) : ( ) : (
<> <>
<span className="flex-1"> <span className="flex-1">{value ?? (missing ? 'Missing — required' : 'Not set')}</span>
{value ?? (missing ? 'Missing — required' : 'Not set')}
</span>
{edit ? ( {edit ? (
<button <button
type="button" type="button"

View File

@@ -238,8 +238,8 @@ export function InlineStagePicker({
<div className="text-sm"> <div className="text-sm">
<p className="font-medium text-foreground">Override transition</p> <p className="font-medium text-foreground">Override transition</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{STAGE_LABELS[stage]} {STAGE_LABELS[overrideTarget]} isn&apos;t a standard next {STAGE_LABELS[stage]} {STAGE_LABELS[overrideTarget]} isn&apos;t a standard
step. The change will be flagged in the audit log. next step. The change will be flagged in the audit log.
</p> </p>
</div> </div>
</div> </div>
@@ -355,8 +355,8 @@ export function InlineStagePicker({
<AlertDialogDescription> <AlertDialogDescription>
This interest has {linkedBerthCount} linked{' '} This interest has {linkedBerthCount} linked{' '}
{linkedBerthCount === 1 ? 'berth' : 'berths'}. Going back to <strong>Open</strong>{' '} {linkedBerthCount === 1 ? 'berth' : 'berths'}. Going back to <strong>Open</strong>{' '}
usually means restarting the lead keeping the berth links would leave them showing as usually means restarting the lead keeping the berth links would leave them showing
under offer on the public map for a deal that&apos;s no longer in progress. as under offer on the public map for a deal that&apos;s no longer in progress.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:justify-end"> <AlertDialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:justify-end">

View File

@@ -129,9 +129,7 @@ export function Topbar({ ports, user }: TopbarProps) {
straight at the new path. The Reminders section's straight at the new path. The Reminders section's
useCreateFromUrl handler still picks up ?create=1. */} useCreateFromUrl handler still picks up ?create=1. */}
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() => router.push(`${base}/inbox?create=1#reminders` as unknown as Route)}
router.push(`${base}/inbox?create=1#reminders` as unknown as Route)
}
> >
New Reminder New Reminder
</DropdownMenuItem> </DropdownMenuItem>