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