Phase 18: Auto timezone detection and ability to change timezone #21
@@ -30,6 +30,8 @@ import {
|
||||
fetchAdminMembers,
|
||||
fetchAdminCalendars,
|
||||
setSharedCalendar,
|
||||
fetchAdminTimezone,
|
||||
setAdminTimezone,
|
||||
type AdminMember,
|
||||
type AdminCalendar,
|
||||
} from '../api/client.js';
|
||||
@@ -60,6 +62,9 @@ export function AdminPage() {
|
||||
// Shared calendar picker state
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null);
|
||||
|
||||
// Timezone picker state
|
||||
const [timezoneInput, setTimezoneInput] = useState<string | null>(null);
|
||||
|
||||
// Members query
|
||||
const membersQuery = useQuery({
|
||||
queryKey: ['admin', 'members'],
|
||||
@@ -82,6 +87,42 @@ export function AdminPage() {
|
||||
// Effective selected = user pick OR fallback to current saved
|
||||
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
|
||||
const sharedCalMutation = useMutation({
|
||||
mutationFn: (calId: number) => setSharedCalendar(calId),
|
||||
@@ -180,7 +221,7 @@ export function AdminPage() {
|
||||
</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>
|
||||
|
||||
<p
|
||||
@@ -286,6 +327,148 @@ export function AdminPage() {
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Credential sheet — admin-rotate or admin-add */}
|
||||
|
||||
Reference in New Issue
Block a user