feat(09-04): ProfileTimezoneSection (Combobox + live current time)

- Combobox from Popover + Command with Intl.supportedValuesOf + EXTRA_ALLOWED_TIMEZONES
- debounced 400ms PUT /api/me/timezone on selection
- live clock via setInterval 60s re-tick
- toast.success/error; inline destructive error on save failure
This commit is contained in:
lorentz 2026-05-10 07:40:48 -04:00
parent da13caf9cb
commit 5aef2559dd

View file

@ -0,0 +1,165 @@
'use client';
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { ChevronsUpDown, Check } from 'lucide-react';
import { toast } from 'sonner';
import ProfileSectionSkeleton from './ProfileSectionSkeleton';
const EXTRA_ALLOWED_TIMEZONES = ['UTC', 'Etc/UTC', 'GMT', 'Etc/GMT'];
function formatCurrentTime(zone: string): string {
try {
return new Intl.DateTimeFormat('en-US', {
dateStyle: 'short',
timeStyle: 'short',
timeZone: zone,
}).format(new Date());
} catch {
return '';
}
}
export function ProfileTimezoneSection() {
const [loading, setLoading] = useState(true);
const [selectedZone, setSelectedZone] = useState<string>('UTC');
const [open, setOpen] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// Tick state to force re-render every minute for live clock
const [, setTick] = useState(0);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Build sorted, de-duplicated timezone list once
const timezones = useMemo(() => {
let zones: string[] = [];
try {
zones = Intl.supportedValuesOf('timeZone');
} catch {
zones = [];
}
const combined = new Set([...zones, ...EXTRA_ALLOWED_TIMEZONES]);
return Array.from(combined).sort();
}, []);
// Fetch current timezone on mount
useEffect(() => {
fetch('/api/me/timezone')
.then((r) => r.json())
.then((data: { timezone: string }) => {
setSelectedZone(data.timezone);
setLoading(false);
})
.catch(() => {
setLoading(false);
});
}, []);
// Re-tick every 60s so the "current time" line stays fresh
useEffect(() => {
const interval = setInterval(() => {
setTick((t) => t + 1);
}, 60000);
return () => clearInterval(interval);
}, []);
const handleSelect = useCallback(
(zone: string) => {
setOpen(false);
setSaveError(null);
const previousZone = selectedZone;
setSelectedZone(zone);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(async () => {
try {
const res = await fetch('/api/me/timezone', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ timezone: zone }),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data: { timezone: string } = await res.json();
setSelectedZone(data.timezone);
toast.success('Timezone updated');
} catch {
setSelectedZone(previousZone);
setSaveError("Couldn't save. Try again.");
toast.error('Failed to update timezone');
}
}, 400);
},
[selectedZone],
);
if (loading) {
return <ProfileSectionSkeleton />;
}
return (
<Card>
<CardHeader className="px-4 pt-4 pb-0">
<CardTitle className="text-xl font-semibold">Timezone</CardTitle>
</CardHeader>
<CardContent className="px-4 py-4">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
<span className="truncate">{selectedZone}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="Search timezones…" />
<CommandEmpty>No matches.</CommandEmpty>
<CommandList>
{timezones.map((zone) => (
<CommandItem
key={zone}
value={zone}
onSelect={handleSelect}
>
<span className="flex-1">{zone}</span>
{selectedZone === zone && (
<Check className="h-4 w-4 shrink-0" />
)}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{saveError && (
<p className="mt-1 text-xs text-destructive">{saveError}</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
Your current time:{' '}
<span className="font-mono tabular-nums">{formatCurrentTime(selectedZone)}</span>{' '}
in {selectedZone}
</p>
</CardContent>
</Card>
);
}