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

@@ -400,8 +400,9 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
onChange={setAttachedYachtIds}
/>
<p className="text-[11px] text-muted-foreground">
Adding a yacht transfers its ownership to this company (logged in the yacht&apos;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&apos;s audit trail). Skip if you only want to associate without changing
ownership.
</p>
</div>
<div className="flex flex-wrap gap-2">
@@ -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}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</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}>
<CommandInput placeholder="Search…" onValueChange={setSearch} />
<CommandList>
@@ -683,10 +690,7 @@ function EntityMultiPicker({
onSelect={() => toggle(opt.value)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
isSelected ? 'opacity-100' : 'opacity-0',
)}
className={cn('mr-2 h-4 w-4', isSelected ? 'opacity-100' : 'opacity-0')}
/>
{opt.label}
</CommandItem>