= {
'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 1–28 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',
diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx
index b63306f..8ca84fc 100644
--- a/client/src/pages/AdminPage.tsx
+++ b/client/src/pages/AdminPage.tsx
@@ -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 {
|
- {new Date(u.created_at).toLocaleDateString(locale)}
+ {new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })}
|
- {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 }) : '—'}
|
@@ -584,7 +584,7 @@ export default function AdminPage(): React.ReactElement {
{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}`}
diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts
index 1ce5211..8387c4e 100644
--- a/client/src/store/authStore.ts
+++ b/client/src/store/authStore.ts
@@ -23,6 +23,7 @@ interface AuthState {
error: string | null
demoMode: boolean
hasMapsKey: boolean
+ serverTimezone: string
login: (email: string, password: string) => Promise
completeMfaLogin: (mfaToken: string, code: string) => Promise
@@ -36,6 +37,7 @@ interface AuthState {
deleteAvatar: () => Promise
setDemoMode: (val: boolean) => void
setHasMapsKey: (val: boolean) => void
+ setServerTimezone: (tz: string) => void
demoLogin: () => Promise
}
@@ -47,6 +49,7 @@ export const useAuthStore = create((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((set, get) => ({
},
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
+ setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
demoLogin: async () => {
set({ isLoading: true, error: null })
diff --git a/client/src/types.ts b/client/src/types.ts
index e5913ab..7dd58f1 100644
--- a/client/src/types.ts
+++ b/client/src/types.ts
@@ -279,6 +279,7 @@ export interface AppConfig {
oidc_display_name?: string
has_maps_key?: boolean
allowed_file_types?: string
+ timezone?: string
}
// Translation function type
diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts
index 4fd3409..7e7ebc0 100644
--- a/client/src/utils/formatters.ts
+++ b/client/src/utils/formatters.ts
@@ -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 {
diff --git a/docker-compose.yml b/docker-compose.yml
index 1acc607..91a97e9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -9,6 +9,7 @@ services:
- JWT_SECRET=${JWT_SECRET:-}
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
- PORT=3000
+ - TZ=${TZ:-UTC}
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts
index c7aaf2a..eea850f 100644
--- a/server/src/routes/admin.ts
+++ b/server/src/routes/admin.ts
@@ -12,6 +12,11 @@ const router = express.Router();
router.use(authenticate, adminOnly);
+function utcSuffix(ts: string | null | undefined): string | null {
+ if (!ts) return null;
+ return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
+}
+
router.get('/users', (req: Request, res: Response) => {
const users = db.prepare(
'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
@@ -21,7 +26,13 @@ router.get('/users', (req: Request, res: Response) => {
const { getOnlineUserIds } = require('../websocket');
onlineUserIds = getOnlineUserIds();
} catch { /* */ }
- const usersWithStatus = users.map(u => ({ ...u, online: onlineUserIds.has(u.id) }));
+ const usersWithStatus = users.map(u => ({
+ ...u,
+ created_at: utcSuffix(u.created_at),
+ updated_at: utcSuffix(u.updated_at as string),
+ last_login: utcSuffix(u.last_login),
+ online: onlineUserIds.has(u.id),
+ }));
res.json({ users: usersWithStatus });
});
diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts
index b5518ef..de58aeb 100644
--- a/server/src/routes/auth.ts
+++ b/server/src/routes/auth.ts
@@ -28,6 +28,11 @@ function getPendingMfaSecret(userId: number): string | null {
return row.secret;
}
+function utcSuffix(ts: string | null | undefined): string | null {
+ if (!ts) return null;
+ return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
+}
+
function stripUserForClient(user: User): Record {
const {
password_hash: _p,
@@ -39,6 +44,9 @@ function stripUserForClient(user: User): Record {
} = user;
return {
...rest,
+ created_at: utcSuffix(rest.created_at),
+ updated_at: utcSuffix(rest.updated_at),
+ last_login: utcSuffix(rest.last_login),
mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true),
};
}
@@ -146,6 +154,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
demo_mode: isDemo,
demo_email: isDemo ? 'demo@trek.app' : undefined,
demo_password: isDemo ? 'demo12345' : undefined,
+ timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
});
});
diff --git a/server/src/routes/backup.ts b/server/src/routes/backup.ts
index e18190d..832b355 100644
--- a/server/src/routes/backup.ts
+++ b/server/src/routes/backup.ts
@@ -212,17 +212,30 @@ router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request,
router.get('/auto-settings', (_req: Request, res: Response) => {
try {
- res.json({ settings: scheduler.loadSettings() });
+ const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
+ res.json({ settings: scheduler.loadSettings(), timezone: tz });
} catch (err: unknown) {
console.error('[backup] GET auto-settings:', err);
res.status(500).json({ error: 'Could not load backup settings' });
}
});
+function parseIntField(raw: unknown, fallback: number): number {
+ if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
+ if (typeof raw === 'string' && raw.trim() !== '') {
+ const n = parseInt(raw, 10);
+ if (Number.isFinite(n)) return n;
+ }
+ return fallback;
+}
+
function parseAutoBackupBody(body: Record): {
enabled: boolean;
interval: string;
keep_days: number;
+ hour: number;
+ day_of_week: number;
+ day_of_month: number;
} {
const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1;
const rawInterval = body.interval;
@@ -230,17 +243,11 @@ function parseAutoBackupBody(body: Record): {
typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval)
? rawInterval
: 'daily';
- const rawKeep = body.keep_days;
- let keepNum: number;
- if (typeof rawKeep === 'number' && Number.isFinite(rawKeep)) {
- keepNum = Math.floor(rawKeep);
- } else if (typeof rawKeep === 'string' && rawKeep.trim() !== '') {
- keepNum = parseInt(rawKeep, 10);
- } else {
- keepNum = NaN;
- }
- const keep_days = Number.isFinite(keepNum) && keepNum >= 0 ? keepNum : 7;
- return { enabled, interval, keep_days };
+ const keep_days = Math.max(0, parseIntField(body.keep_days, 7));
+ const hour = Math.min(23, Math.max(0, parseIntField(body.hour, 2)));
+ const day_of_week = Math.min(6, Math.max(0, parseIntField(body.day_of_week, 0)));
+ const day_of_month = Math.min(28, Math.max(1, parseIntField(body.day_of_month, 1)));
+ return { enabled, interval, keep_days, hour, day_of_week, day_of_month };
}
router.put('/auto-settings', (req: Request, res: Response) => {
diff --git a/server/src/scheduler.ts b/server/src/scheduler.ts
index 2a8a75d..a3272c2 100644
--- a/server/src/scheduler.ts
+++ b/server/src/scheduler.ts
@@ -8,30 +8,48 @@ const backupsDir = path.join(dataDir, 'backups');
const uploadsDir = path.join(__dirname, '../uploads');
const settingsFile = path.join(dataDir, 'backup-settings.json');
-const CRON_EXPRESSIONS: Record = {
- hourly: '0 * * * *',
- daily: '0 2 * * *',
- weekly: '0 2 * * 0',
- monthly: '0 2 1 * *',
-};
-
-const VALID_INTERVALS = Object.keys(CRON_EXPRESSIONS);
+const VALID_INTERVALS = ['hourly', 'daily', 'weekly', 'monthly'];
+const VALID_DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6]; // 0=Sunday
+const VALID_HOURS = Array.from({ length: 24 }, (_, i) => i);
interface BackupSettings {
enabled: boolean;
interval: string;
keep_days: number;
+ hour: number;
+ day_of_week: number;
+ day_of_month: number;
+}
+
+function buildCronExpression(settings: BackupSettings): string {
+ const hour = VALID_HOURS.includes(settings.hour) ? settings.hour : 2;
+ const dow = VALID_DAYS_OF_WEEK.includes(settings.day_of_week) ? settings.day_of_week : 0;
+ const dom = settings.day_of_month >= 1 && settings.day_of_month <= 28 ? settings.day_of_month : 1;
+
+ switch (settings.interval) {
+ case 'hourly': return '0 * * * *';
+ case 'daily': return `0 ${hour} * * *`;
+ case 'weekly': return `0 ${hour} * * ${dow}`;
+ case 'monthly': return `0 ${hour} ${dom} * *`;
+ default: return `0 ${hour} * * *`;
+ }
}
let currentTask: ScheduledTask | null = null;
+function getDefaults(): BackupSettings {
+ return { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 };
+}
+
function loadSettings(): BackupSettings {
+ let settings = getDefaults();
try {
if (fs.existsSync(settingsFile)) {
- return JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
+ const saved = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
+ settings = { ...settings, ...saved };
}
} catch (e) {}
- return { enabled: false, interval: 'daily', keep_days: 7 };
+ return settings;
}
function saveSettings(settings: BackupSettings): void {
@@ -104,9 +122,10 @@ function start(): void {
return;
}
- const expression = CRON_EXPRESSIONS[settings.interval] || CRON_EXPRESSIONS.daily;
- currentTask = cron.schedule(expression, runBackup);
- console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
+ const expression = buildCronExpression(settings);
+ const tz = process.env.TZ || 'UTC';
+ currentTask = cron.schedule(expression, runBackup, { timezone: tz });
+ console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
}
// Demo mode: hourly reset of demo user data
|