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

@@ -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({
</p>
<dl className="space-y-1.5">
{optional.map((row) => (
<PreviewRow
key={row.key}
label={row.label}
value={row.value}
edit={row.edit}
/>
<PreviewRow key={row.key} label={row.label} value={row.value} edit={row.edit} />
))}
</dl>
</div>
@@ -477,9 +470,7 @@ function PreviewRow({
)
) : (
<>
<span className="flex-1">
{value ?? (missing ? 'Missing — required' : 'Not set')}
</span>
<span className="flex-1">{value ?? (missing ? 'Missing — required' : 'Not set')}</span>
{edit ? (
<button
type="button"