feat: Add timezone and time format preferences with auto-detection
This commit implements comprehensive timezone and time format customization:
## Backend Changes
- Added timeFormat field ('12h' | '24h') to user preferences JSONB in user entity
- Timezone field already existed in user entity, now actively used
- Backend ready to accept timezone on registration
## Frontend Components (2 new)
- TimeZoneSelector: Dropdown with timezones grouped by region (Americas, Europe, Asia, Pacific, Africa)
* Auto-detect button to detect browser timezone
* Save functionality with success/error feedback
* Integrated into Settings > Preferences section
- TimeFormatSelector: Radio buttons to choose 12h vs 24h format
* Live preview showing current time in selected format
* Save functionality with user feedback
* Integrated into Settings > Preferences section
## Timezone Auto-Detection
- Register function now auto-detects user's timezone via Intl.DateTimeFormat()
- Detected timezone sent to backend during registration
- Timezone stored in user profile for persistent preference
## Enhanced useLocalizedDate Hook
- Added useAuth integration to access user timezone and timeFormat preferences
- Installed and integrated date-fns-tz for timezone conversion
- New format() function with timezone support via useTimezone option
- New formatTime() function respecting user's 12h/24h preference
- New formatDateTime() function combining date, time, and timezone
- All formatting now respects user's:
* Language (existing: en, es, fr, pt-BR, zh-CN)
* Timezone (user-selected or auto-detected)
* Time format (12h with AM/PM or 24h)
## Settings Page Updates
- Added TimeZoneSelector to Preferences card
- Added TimeFormatSelector to Preferences card
- Visual separators (Dividers) between preference sections
- Settings now show: Language | Units | Timezone | Time Format
## Translations
- Enhanced settings.json with timezone and time format keys:
* preferences.timezone, autoDetectTimezone, timezoneUpdated
* preferences.12hour, 24hour, timeFormatUpdated
## User Experience Flow
1. User registers → timezone auto-detected and saved
2. User can change timezone in Settings > Preferences > Time Zone
3. User can change time format in Settings > Preferences > Time Format
4. All dates/times throughout app respect these preferences
5. Changes persist across sessions
Files changed: 10 files
New dependencies: date-fns-tz
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
127
maternal-web/components/settings/TimeFormatSelector.tsx
Normal file
127
maternal-web/components/settings/TimeFormatSelector.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
FormControl,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
Radio,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { Save, Schedule } from '@mui/icons-material';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { usersApi } from '@/lib/api/users';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
export function TimeFormatSelector() {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const { t } = useTranslation('settings');
|
||||
const [timeFormat, setTimeFormat] = useState<'12h' | '24h'>(
|
||||
user?.preferences?.timeFormat || '12h'
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
try {
|
||||
await usersApi.updateProfile({
|
||||
preferences: {
|
||||
...user?.preferences,
|
||||
timeFormat,
|
||||
},
|
||||
});
|
||||
await refreshUser();
|
||||
setSuccessMessage('Time format updated successfully');
|
||||
} catch (err: any) {
|
||||
console.error('Failed to update time format:', err);
|
||||
setError(err.response?.data?.message || 'Failed to update time format');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentTime = new Date();
|
||||
const preview12h = currentTime.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
const preview24h = currentTime.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Schedule color="action" />
|
||||
<Typography variant="subtitle1" fontWeight="600">
|
||||
Time Format
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccessMessage(null)}>
|
||||
{successMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormControl component="fieldset" sx={{ mb: 2 }}>
|
||||
<RadioGroup
|
||||
value={timeFormat}
|
||||
onChange={(e) => setTimeFormat(e.target.value as '12h' | '24h')}
|
||||
>
|
||||
<FormControlLabel
|
||||
value="12h"
|
||||
control={<Radio disabled={isLoading} />}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body1">12-hour format</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Example: {preview12h}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="24h"
|
||||
control={<Radio disabled={isLoading} />}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body1">24-hour format</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Example: {preview24h}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={isLoading ? <CircularProgress size={20} color="inherit" /> : <Save />}
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || timeFormat === user?.preferences?.timeFormat}
|
||||
>
|
||||
{isLoading ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
167
maternal-web/components/settings/TimeZoneSelector.tsx
Normal file
167
maternal-web/components/settings/TimeZoneSelector.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { Save, AccessTime } from '@mui/icons-material';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { usersApi } from '@/lib/api/users';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
// Common timezones grouped by region
|
||||
const TIMEZONES = {
|
||||
'Americas': [
|
||||
{ value: 'America/New_York', label: 'Eastern Time (US & Canada)' },
|
||||
{ value: 'America/Chicago', label: 'Central Time (US & Canada)' },
|
||||
{ value: 'America/Denver', label: 'Mountain Time (US & Canada)' },
|
||||
{ value: 'America/Los_Angeles', label: 'Pacific Time (US & Canada)' },
|
||||
{ value: 'America/Anchorage', label: 'Alaska' },
|
||||
{ value: 'Pacific/Honolulu', label: 'Hawaii' },
|
||||
{ value: 'America/Phoenix', label: 'Arizona' },
|
||||
{ value: 'America/Toronto', label: 'Toronto' },
|
||||
{ value: 'America/Vancouver', label: 'Vancouver' },
|
||||
{ value: 'America/Mexico_City', label: 'Mexico City' },
|
||||
{ value: 'America/Sao_Paulo', label: 'São Paulo' },
|
||||
{ value: 'America/Buenos_Aires', label: 'Buenos Aires' },
|
||||
],
|
||||
'Europe': [
|
||||
{ value: 'Europe/London', label: 'London' },
|
||||
{ value: 'Europe/Paris', label: 'Paris' },
|
||||
{ value: 'Europe/Berlin', label: 'Berlin' },
|
||||
{ value: 'Europe/Rome', label: 'Rome' },
|
||||
{ value: 'Europe/Madrid', label: 'Madrid' },
|
||||
{ value: 'Europe/Amsterdam', label: 'Amsterdam' },
|
||||
{ value: 'Europe/Brussels', label: 'Brussels' },
|
||||
{ value: 'Europe/Vienna', label: 'Vienna' },
|
||||
{ value: 'Europe/Athens', label: 'Athens' },
|
||||
{ value: 'Europe/Moscow', label: 'Moscow' },
|
||||
],
|
||||
'Asia': [
|
||||
{ value: 'Asia/Dubai', label: 'Dubai' },
|
||||
{ value: 'Asia/Kolkata', label: 'India (Kolkata)' },
|
||||
{ value: 'Asia/Shanghai', label: 'China (Shanghai)' },
|
||||
{ value: 'Asia/Hong_Kong', label: 'Hong Kong' },
|
||||
{ value: 'Asia/Singapore', label: 'Singapore' },
|
||||
{ value: 'Asia/Tokyo', label: 'Tokyo' },
|
||||
{ value: 'Asia/Seoul', label: 'Seoul' },
|
||||
{ value: 'Asia/Bangkok', label: 'Bangkok' },
|
||||
{ value: 'Asia/Jakarta', label: 'Jakarta' },
|
||||
],
|
||||
'Pacific': [
|
||||
{ value: 'Australia/Sydney', label: 'Sydney' },
|
||||
{ value: 'Australia/Melbourne', label: 'Melbourne' },
|
||||
{ value: 'Australia/Brisbane', label: 'Brisbane' },
|
||||
{ value: 'Pacific/Auckland', label: 'Auckland' },
|
||||
{ value: 'Pacific/Fiji', label: 'Fiji' },
|
||||
],
|
||||
'Africa': [
|
||||
{ value: 'Africa/Cairo', label: 'Cairo' },
|
||||
{ value: 'Africa/Johannesburg', label: 'Johannesburg' },
|
||||
{ value: 'Africa/Lagos', label: 'Lagos' },
|
||||
{ value: 'Africa/Nairobi', label: 'Nairobi' },
|
||||
],
|
||||
};
|
||||
|
||||
export function TimeZoneSelector() {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const { t } = useTranslation('settings');
|
||||
const [timezone, setTimezone] = useState(user?.timezone || 'UTC');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
try {
|
||||
await usersApi.updateProfile({ timezone });
|
||||
await refreshUser();
|
||||
setSuccessMessage('Timezone updated successfully');
|
||||
} catch (err: any) {
|
||||
console.error('Failed to update timezone:', err);
|
||||
setError(err.response?.data?.message || 'Failed to update timezone');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoDetect = () => {
|
||||
const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
setTimezone(detectedTimezone);
|
||||
setSuccessMessage(`Auto-detected timezone: ${detectedTimezone}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<AccessTime color="action" />
|
||||
<Typography variant="subtitle1" fontWeight="600">
|
||||
Time Zone
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccessMessage(null)}>
|
||||
{successMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>Time Zone</InputLabel>
|
||||
<Select
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
label="Time Zone"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{Object.entries(TIMEZONES).map(([region, zones]) => [
|
||||
<MenuItem key={region} disabled sx={{ fontWeight: 'bold', color: 'text.primary' }}>
|
||||
{region}
|
||||
</MenuItem>,
|
||||
...zones.map((zone) => (
|
||||
<MenuItem key={zone.value} value={zone.value} sx={{ pl: 4 }}>
|
||||
{zone.label}
|
||||
</MenuItem>
|
||||
)),
|
||||
])}
|
||||
<MenuItem value="UTC">UTC (Universal Time)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleAutoDetect}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Auto-Detect
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={isLoading ? <CircularProgress size={20} color="inherit" /> : <Save />}
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || timezone === user?.timezone}
|
||||
>
|
||||
{isLoading ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user