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:
parent
da13caf9cb
commit
5aef2559dd
1 changed files with 165 additions and 0 deletions
165
components/mobile/profile/ProfileTimezoneSection.tsx
Normal file
165
components/mobile/profile/ProfileTimezoneSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue