From 0ab7055cf1bf1040f1df19da3140e1285c9be32c Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 12 May 2026 15:48:51 +0200 Subject: [PATCH] feat(dashboard): local-time greeting + timezone-drift banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- .../admin/admin-sections-browser.tsx | 13 +- src/components/berths/berth-detail-header.tsx | 9 +- src/components/companies/company-form.tsx | 20 +- src/components/dashboard/activity-feed.tsx | 4 +- src/components/dashboard/dashboard-shell.tsx | 35 +- .../dashboard/timezone-drift-banner.tsx | 142 +++++++ .../documents/eoi-generate-dialog.tsx | 17 +- .../interests/inline-stage-picker.tsx | 368 +++++++++--------- src/components/layout/topbar.tsx | 4 +- 9 files changed, 395 insertions(+), 217 deletions(-) create mode 100644 src/components/dashboard/timezone-drift-banner.tsx diff --git a/src/components/admin/admin-sections-browser.tsx b/src/components/admin/admin-sections-browser.tsx index 3a52f45c..7755dbe8 100644 --- a/src/components/admin/admin-sections-browser.tsx +++ b/src/components/admin/admin-sections-browser.tsx @@ -95,7 +95,12 @@ export function AdminSectionsBrowser({ portSlug, groups }: AdminSectionsBrowserP

{filteredMatches.map((s) => ( - + ))}
@@ -138,7 +143,11 @@ function SectionCard({ href={`/${portSlug}/admin/${section.href}` as any} className="block group" > - +
diff --git a/src/components/berths/berth-detail-header.tsx b/src/components/berths/berth-detail-header.tsx index 3cdd3f77..413699d0 100644 --- a/src/components/berths/berth-detail-header.tsx +++ b/src/components/berths/berth-detail-header.tsx @@ -368,7 +368,10 @@ function InterestLinkPicker({ - + @@ -415,7 +418,9 @@ function InterestLinkPicker({ > {stageLabel(opt.pipelineStage)} - {value === opt.id ? : null} + {value === opt.id ? ( + + ) : null} ))} diff --git a/src/components/companies/company-form.tsx b/src/components/companies/company-form.tsx index b01fcd6a..a68e2655 100644 --- a/src/components/companies/company-form.tsx +++ b/src/components/companies/company-form.tsx @@ -400,8 +400,9 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) { onChange={setAttachedYachtIds} />

- Adding a yacht transfers its ownership to this company (logged in the yacht's - audit trail). Skip if you only want to associate without changing ownership. + Adding a yacht transfers its ownership to this company (logged in the + yacht's audit trail). Skip if you only want to associate without changing + ownership.

@@ -662,13 +663,19 @@ function EntityMultiPicker({ size="sm" role="combobox" 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} - + @@ -683,10 +690,7 @@ function EntityMultiPicker({ onSelect={() => toggle(opt.value)} > {opt.label} diff --git a/src/components/dashboard/activity-feed.tsx b/src/components/dashboard/activity-feed.tsx index 97c372cf..05301117 100644 --- a/src/components/dashboard/activity-feed.tsx +++ b/src/components/dashboard/activity-feed.tsx @@ -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(' · '); } diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index 019eaddc..ef156fc0 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -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(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() {

{greeting}

+ + (null); + const [stored, setStored] = useState(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('/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 ( +
+
+ +
+

+ You appear to be in{' '} + {formatTimezoneLabel(detected)}. +

+

+ Your CRM is set to {formatTimezoneLabel(stored)}. + Reminders and the daily digest follow the CRM setting. +

+
+
+
+ + +
+
+ ); +} diff --git a/src/components/documents/eoi-generate-dialog.tsx b/src/components/documents/eoi-generate-dialog.tsx index 0f6b7bcd..400fa39a 100644 --- a/src/components/documents/eoi-generate-dialog.tsx +++ b/src/components/documents/eoi-generate-dialog.tsx @@ -145,8 +145,7 @@ export function EoiGenerateDialog({ value: ctx.client.fullName, present: !!ctx.client.fullName, edit: { - onSave: async (next: string | null) => - await patchClient({ fullName: next ?? '' }), + onSave: async (next: string | null) => await patchClient({ fullName: next ?? '' }), placeholder: 'Full legal name', }, }, @@ -194,8 +193,7 @@ export function EoiGenerateDialog({ value: ctx.yacht?.name ?? null, edit: ctx.yacht ? { - onSave: async (next: string | null) => - await patchYacht({ name: next ?? '' }), + onSave: async (next: string | null) => await patchYacht({ name: next ?? '' }), placeholder: 'Yacht name', } : undefined, @@ -323,12 +321,7 @@ export function EoiGenerateDialog({

{optional.map((row) => ( - + ))}
@@ -477,9 +470,7 @@ function PreviewRow({ ) ) : ( <> - - {value ?? (missing ? 'Missing — required' : 'Not set')} - + {value ?? (missing ? 'Missing — required' : 'Not set')} {edit ? ( - - stopPropagation && e.stopPropagation()} + { + if (mutation.isPending) return; + setOpen(o); + if (!o) cancelOverride(); + }} > - {overrideTarget ? ( - // Confirm-override view: only reached when the user picked a - // stage that isn't a legal next step. Reason is optional but - // strongly nudged for the audit log. -
-
- -
-

Override transition

-

- {STAGE_LABELS[stage]} → {STAGE_LABELS[overrideTarget]} isn't a standard next - step. The change will be flagged in the audit log. -

+ + + + stopPropagation && e.stopPropagation()} + > + {overrideTarget ? ( + // Confirm-override view: only reached when the user picked a + // stage that isn't a legal next step. Reason is optional but + // strongly nudged for the audit log. +
+
+ +
+

Override transition

+

+ {STAGE_LABELS[stage]} → {STAGE_LABELS[overrideTarget]} isn't a standard + next step. The change will be flagged in the audit log. +

+
+
+
+ +