Phase 18: Auto timezone detection and ability to change timezone #21

Merged
luckberg merged 38 commits from gsd/phase-18-auto-timezone-detection-and-ability-to-change-timezone into main 2026-06-15 09:55:53 -04:00
Showing only changes of commit 43d6689167 - Show all commits
+184 -1
View File
@@ -30,6 +30,8 @@ import {
fetchAdminMembers, fetchAdminMembers,
fetchAdminCalendars, fetchAdminCalendars,
setSharedCalendar, setSharedCalendar,
fetchAdminTimezone,
setAdminTimezone,
type AdminMember, type AdminMember,
type AdminCalendar, type AdminCalendar,
} from '../api/client.js'; } from '../api/client.js';
@@ -60,6 +62,9 @@ export function AdminPage() {
// Shared calendar picker state // Shared calendar picker state
const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null); const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null);
// Timezone picker state
const [timezoneInput, setTimezoneInput] = useState<string | null>(null);
// Members query // Members query
const membersQuery = useQuery({ const membersQuery = useQuery({
queryKey: ['admin', 'members'], queryKey: ['admin', 'members'],
@@ -82,6 +87,42 @@ export function AdminPage() {
// Effective selected = user pick OR fallback to current saved // Effective selected = user pick OR fallback to current saved
const effectiveSelected = selectedCalendarId ?? currentSharedId; const effectiveSelected = selectedCalendarId ?? currentSharedId;
// Timezone query
const timezoneQuery = useQuery({
queryKey: ['admin', 'timezone'],
queryFn: fetchAdminTimezone,
retry: false,
staleTime: 60 * 1000,
});
// Timezone save mutation
const timezoneMutation = useMutation({
mutationFn: (tz: string) => setAdminTimezone(tz),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'timezone'] });
setTimezoneInput(null); // reset local override after save
},
});
// Detected browser timezone (D-02)
const detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Effective timezone input value: local override → stored value → ''
const storedTimezone = timezoneQuery.data?.timezone ?? '';
const effectiveTimezoneInput = timezoneInput ?? storedTimezone;
// Save is disabled when pending, or when input matches what's stored
const timezoneSaveDisabled =
timezoneMutation.isPending ||
effectiveTimezoneInput === '' ||
effectiveTimezoneInput === storedTimezone;
// IANA zones list for the datalist (Intl.supportedValuesOf may not be present in all runtimes)
const ianaZones: string[] =
typeof (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf === 'function'
? (Intl as { supportedValuesOf: (key: string) => string[] }).supportedValuesOf('timeZone')
: [];
// Save shared calendar mutation // Save shared calendar mutation
const sharedCalMutation = useMutation({ const sharedCalMutation = useMutation({
mutationFn: (calId: number) => setSharedCalendar(calId), mutationFn: (calId: number) => setSharedCalendar(calId),
@@ -180,7 +221,7 @@ export function AdminPage() {
</section> </section>
{/* ── SHARED CALENDAR section ──────────────────────────────────────── */} {/* ── SHARED CALENDAR section ──────────────────────────────────────── */}
<section aria-label="Shared Calendar"> <section aria-label="Shared Calendar" style={{ marginBottom: 'var(--space-8, 32px)' }}>
<div style={sectionLabelStyle}>Shared Calendar</div> <div style={sectionLabelStyle}>Shared Calendar</div>
<p <p
@@ -286,6 +327,148 @@ export function AdminPage() {
</> </>
)} )}
</section> </section>
{/* ── TIMEZONE section ─────────────────────────────────────────────── */}
<section aria-label="Timezone">
<div style={sectionLabelStyle}>Timezone</div>
{timezoneQuery.isLoading && (
<div
style={{
color: 'var(--color-text-muted)',
fontSize: 'var(--text-body-size, 15px)',
padding: 'var(--space-2, 8px) 0',
}}
>
Loading timezone
</div>
)}
{timezoneQuery.isError && (
<div
style={{
color: 'var(--color-destructive)',
fontSize: 'var(--text-body-size, 15px)',
padding: 'var(--space-2, 8px) 0',
}}
>
Could not load timezone setting.
</div>
)}
{timezoneQuery.data && (
<>
{/* System-default notice (D-06) */}
{!timezoneQuery.data.isExplicitlySet && (
<div
style={{
fontSize: 'var(--text-label-size, 13px)',
color: 'var(--color-text-muted)',
marginBottom: 'var(--space-3, 12px)',
fontStyle: 'italic',
}}
>
Using system default save a timezone to make it explicit.
</div>
)}
{/* Searchable IANA picker */}
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
<input
type="text"
list="iana-zones"
value={effectiveTimezoneInput}
onChange={(e) => setTimezoneInput(e.target.value)}
placeholder="e.g. America/Chicago"
aria-label="Household timezone"
style={{
width: '100%',
boxSizing: 'border-box',
padding: 'var(--space-2, 8px) var(--space-3, 12px)',
fontSize: 'var(--text-body-size, 15px)',
fontFamily: 'var(--font-family-base)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1, 4px)',
color: 'var(--color-text-primary)',
background: 'var(--color-surface, #ffffff)',
minHeight: '44px',
}}
/>
<datalist id="iana-zones">
{ianaZones.map((tz) => (
<option key={tz} value={tz} />
))}
</datalist>
</div>
{/* Use detected zone affordance (D-02) */}
{detectedTz && detectedTz !== effectiveTimezoneInput && (
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
<button
type="button"
onClick={() => setTimezoneInput(detectedTz)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
fontSize: 'var(--text-label-size, 13px)',
color: 'var(--color-member-0, #4A90D9)',
fontFamily: 'var(--font-family-base)',
textDecoration: 'underline',
}}
>
Use detected: {detectedTz}
</button>
</div>
)}
{/* Save button */}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
disabled={timezoneSaveDisabled}
onClick={() => {
if (effectiveTimezoneInput) {
timezoneMutation.mutate(effectiveTimezoneInput);
}
}}
style={{
background: timezoneSaveDisabled
? 'var(--color-border, #E2E4E9)'
: 'var(--color-member-0, #4A90D9)',
color: '#ffffff',
border: 'none',
cursor: timezoneSaveDisabled ? 'default' : 'pointer',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
minHeight: '44px',
minWidth: '44px',
padding: '0 var(--space-6, 24px)',
borderRadius: 'var(--space-1, 4px)',
fontFamily: 'var(--font-family-base)',
transition: 'background 0.15s ease',
}}
>
{timezoneMutation.isPending ? 'Saving…' : 'Save'}
</button>
</div>
{timezoneMutation.isError && (
<div
style={{
color: 'var(--color-destructive)',
fontSize: 'var(--text-label-size, 13px)',
marginTop: 'var(--space-2, 8px)',
textAlign: 'right',
}}
>
Could not save timezone. Please check the value and try again.
</div>
)}
</>
)}
</section>
</div> </div>
{/* Credential sheet — admin-rotate or admin-add */} {/* Credential sheet — admin-rotate or admin-add */}