feat: add granular auto-backup scheduling and timezone support

Add UI controls for configuring auto-backup schedule with hour, day of
week, and day of month pickers. The hour picker respects the user's
12h/24h time format preference from settings.

Add TZ environment variable support via docker-compose so the container
runs in the configured timezone. The timezone is passed to node-cron for
accurate scheduling and exposed via the API so the UI displays it.

Fix SQLite UTC timestamp handling by appending Z suffix to all timestamps
sent to the client, ensuring proper timezone conversion in the browser.

Made-with: Cursor
This commit is contained in:
Andrei Brebene
2026-03-30 12:24:02 +03:00
parent f1c4155d81
commit cc8be328f9
14 changed files with 225 additions and 43 deletions

View File

@@ -62,28 +62,26 @@ function RootRedirect() {
}
export default function App() {
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey } = useAuthStore()
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone } = useAuthStore()
const { loadSettings } = useSettingsStore()
useEffect(() => {
if (token) {
loadUser()
}
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string }) => {
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string }) => {
if (config?.demo_mode) setDemoMode(true)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.timezone) setServerTimezone(config.timezone)
// Version-based cache invalidation: clear all caches on version change
if (config?.version) {
const storedVersion = localStorage.getItem('trek_app_version')
if (storedVersion && storedVersion !== config.version) {
try {
// Clear all Service Worker caches
if ('caches' in window) {
const names = await caches.keys()
await Promise.all(names.map(n => caches.delete(n)))
}
// Unregister all service workers
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations()
await Promise.all(regs.map(r => r.unregister()))

View File

@@ -3,6 +3,7 @@ import { backupApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { getApiErrorMessage } from '../../types'
const INTERVAL_OPTIONS = [
@@ -21,19 +22,35 @@ const KEEP_OPTIONS = [
{ value: 0, labelKey: 'backup.keep.forever' },
]
const DAYS_OF_WEEK = [
{ value: 0, labelKey: 'backup.dow.sunday' },
{ value: 1, labelKey: 'backup.dow.monday' },
{ value: 2, labelKey: 'backup.dow.tuesday' },
{ value: 3, labelKey: 'backup.dow.wednesday' },
{ value: 4, labelKey: 'backup.dow.thursday' },
{ value: 5, labelKey: 'backup.dow.friday' },
{ value: 6, labelKey: 'backup.dow.saturday' },
]
const HOURS = Array.from({ length: 24 }, (_, i) => i)
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
export default function BackupPanel() {
const [backups, setBackups] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [restoringFile, setRestoringFile] = useState(null)
const [isUploading, setIsUploading] = useState(false)
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
const [serverTimezone, setServerTimezone] = useState('')
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
const fileInputRef = useRef(null)
const toast = useToast()
const { t, language, locale } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const loadBackups = async () => {
setIsLoading(true)
@@ -51,6 +68,7 @@ export default function BackupPanel() {
try {
const data = await backupApi.getAutoSettings()
setAutoSettings(data.settings)
if (data.timezone) setServerTimezone(data.timezone)
} catch {}
}
@@ -147,10 +165,12 @@ export default function BackupPanel() {
const formatDate = (dateStr) => {
if (!dateStr) return '-'
try {
return new Date(dateStr).toLocaleString(locale, {
const opts: Intl.DateTimeFormatOptions = {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
if (serverTimezone) opts.timeZone = serverTimezone
return new Date(dateStr).toLocaleString(locale, opts)
} catch { return dateStr }
}
@@ -331,6 +351,76 @@ export default function BackupPanel() {
</div>
</div>
{/* Hour picker (for daily, weekly, monthly) */}
{autoSettings.interval !== 'hourly' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
<select
value={autoSettings.hour}
onChange={e => handleAutoSettingsChange('hour', parseInt(e.target.value, 10))}
className="w-full sm:w-auto px-3 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-gray-700 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
>
{HOURS.map(h => {
let label: string
if (is12h) {
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
label = `${h12}:00 ${period}`
} else {
label = `${String(h).padStart(2, '0')}:00`
}
return (
<option key={h} value={h}>
{label}
</option>
)
})}
</select>
<p className="text-xs text-gray-400 mt-1">
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
</p>
</div>
)}
{/* Day of week (for weekly) */}
{autoSettings.interval === 'weekly' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
<div className="flex flex-wrap gap-2">
{DAYS_OF_WEEK.map(opt => (
<button
key={opt.value}
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
autoSettings.day_of_week === opt.value
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
}`}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
)}
{/* Day of month (for monthly) */}
{autoSettings.interval === 'monthly' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
<select
value={autoSettings.day_of_month}
onChange={e => handleAutoSettingsChange('day_of_month', parseInt(e.target.value, 10))}
className="w-full sm:w-auto px-3 py-2 rounded-lg text-sm font-medium border border-gray-200 bg-white text-gray-700 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
>
{DAYS_OF_MONTH.map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
<p className="text-xs text-gray-400 mt-1">{t('backup.auto.dayOfMonthHint')}</p>
</div>
)}
{/* Keep duration */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>

View File

@@ -1007,7 +1007,27 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'backup.auto.enable': 'Auto-Backup aktivieren',
'backup.auto.enableHint': 'Backups werden automatisch nach dem gewählten Zeitplan erstellt',
'backup.auto.interval': 'Intervall',
'backup.auto.hour': 'Ausführung um',
'backup.auto.hourHint': 'Lokale Serverzeit ({format}-Format)',
'backup.auto.dayOfWeek': 'Wochentag',
'backup.auto.dayOfMonth': 'Tag des Monats',
'backup.auto.dayOfMonthHint': 'Auf 128 beschränkt, um mit allen Monaten kompatibel zu sein',
'backup.auto.scheduleSummary': 'Zeitplan',
'backup.auto.summaryDaily': 'Täglich um {hour}:00',
'backup.auto.summaryWeekly': 'Jeden {day} um {hour}:00',
'backup.auto.summaryMonthly': 'Am {day}. jedes Monats um {hour}:00',
'backup.auto.envLocked': 'Docker',
'backup.auto.envLockedHint': 'Auto-Backup wird über Docker-Umgebungsvariablen konfiguriert. Ändern Sie Ihre docker-compose.yml und starten Sie den Container neu.',
'backup.auto.copyEnv': 'Docker-Umgebungsvariablen kopieren',
'backup.auto.envCopied': 'Docker-Umgebungsvariablen in die Zwischenablage kopiert',
'backup.auto.keepLabel': 'Alte Backups löschen nach',
'backup.dow.sunday': 'So',
'backup.dow.monday': 'Mo',
'backup.dow.tuesday': 'Di',
'backup.dow.wednesday': 'Mi',
'backup.dow.thursday': 'Do',
'backup.dow.friday': 'Fr',
'backup.dow.saturday': 'Sa',
'backup.interval.hourly': 'Stündlich',
'backup.interval.daily': 'Täglich',
'backup.interval.weekly': 'Wöchentlich',

View File

@@ -1007,7 +1007,27 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'backup.auto.enable': 'Enable auto-backup',
'backup.auto.enableHint': 'Backups will be created automatically on the chosen schedule',
'backup.auto.interval': 'Interval',
'backup.auto.hour': 'Run at hour',
'backup.auto.hourHint': 'Server local time ({format} format)',
'backup.auto.dayOfWeek': 'Day of week',
'backup.auto.dayOfMonth': 'Day of month',
'backup.auto.dayOfMonthHint': 'Limited to 128 for compatibility with all months',
'backup.auto.scheduleSummary': 'Schedule',
'backup.auto.summaryDaily': 'Every day at {hour}:00',
'backup.auto.summaryWeekly': 'Every {day} at {hour}:00',
'backup.auto.summaryMonthly': 'Day {day} of every month at {hour}:00',
'backup.auto.envLocked': 'Docker',
'backup.auto.envLockedHint': 'Auto-backup is configured via Docker environment variables. To change these settings, update your docker-compose.yml and restart the container.',
'backup.auto.copyEnv': 'Copy Docker env vars',
'backup.auto.envCopied': 'Docker env vars copied to clipboard',
'backup.auto.keepLabel': 'Delete old backups after',
'backup.dow.sunday': 'Sun',
'backup.dow.monday': 'Mon',
'backup.dow.tuesday': 'Tue',
'backup.dow.wednesday': 'Wed',
'backup.dow.thursday': 'Thu',
'backup.dow.friday': 'Fri',
'backup.dow.saturday': 'Sat',
'backup.interval.hourly': 'Hourly',
'backup.interval.daily': 'Daily',
'backup.interval.weekly': 'Weekly',

View File

@@ -52,7 +52,7 @@ interface UpdateInfo {
}
export default function AdminPage(): React.ReactElement {
const { demoMode } = useAuthStore()
const { demoMode, serverTimezone } = useAuthStore()
const { t, locale } = useTranslation()
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
const TABS = [
@@ -512,10 +512,10 @@ export default function AdminPage(): React.ReactElement {
</span>
</td>
<td className="px-5 py-3 text-sm text-slate-500">
{new Date(u.created_at).toLocaleDateString(locale)}
{new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })}
</td>
<td className="px-5 py-3 text-sm text-slate-500">
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12, timeZone: serverTimezone }) : '—'}
</td>
<td className="px-5 py-3">
<div className="flex items-center gap-2 justify-end">
@@ -584,7 +584,7 @@ export default function AdminPage(): React.ReactElement {
</div>
<div className="text-xs text-slate-400 mt-0.5">
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale)}`}
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale, { timeZone: serverTimezone })}`}
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
</div>
</div>

View File

@@ -23,6 +23,7 @@ interface AuthState {
error: string | null
demoMode: boolean
hasMapsKey: boolean
serverTimezone: string
login: (email: string, password: string) => Promise<LoginResult>
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
@@ -36,6 +37,7 @@ interface AuthState {
deleteAvatar: () => Promise<void>
setDemoMode: (val: boolean) => void
setHasMapsKey: (val: boolean) => void
setServerTimezone: (tz: string) => void
demoLogin: () => Promise<AuthResponse>
}
@@ -47,6 +49,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
error: null,
demoMode: localStorage.getItem('demo_mode') === 'true',
hasMapsKey: false,
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
login: async (email: string, password: string) => {
set({ isLoading: true, error: null })
@@ -201,6 +204,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
demoLogin: async () => {
set({ isLoading: true, error: null })

View File

@@ -279,6 +279,7 @@ export interface AppConfig {
oidc_display_name?: string
has_maps_key?: boolean
allowed_file_types?: string
timezone?: string
}
// Translation function type

View File

@@ -6,11 +6,13 @@ export function currencyDecimals(currency: string): number {
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2
}
export function formatDate(dateStr: string | null | undefined, locale: string): string | null {
export function formatDate(dateStr: string | null | undefined, locale: string, timeZone?: string): string | null {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, {
const opts: Intl.DateTimeFormatOptions = {
weekday: 'short', day: 'numeric', month: 'short',
})
}
if (timeZone) opts.timeZone = timeZone
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, opts)
}
export function formatTime(timeStr: string | null | undefined, locale: string, timeFormat: string): string {