Phase 18: Auto timezone detection and ability to change timezone #21
@@ -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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user