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"
>
-
+
- 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.
+