feat: timezone support + granular backup schedule — closes #131

Based on PR #135 by @andreibrebene with adjustments:
- TZ environment variable for Docker timezone support
- Granular auto-backup schedule (hour, day of week, day of month)
- UTC timestamp fix for admin panel
- Server timezone exposed in app-config API
- Replaced native selects with CustomSelect for consistent UI
- Backup schedule UI with 12h/24h time format support

Thanks @andreibrebene for the implementation!
This commit is contained in:
Maurice
2026-03-30 14:02:27 +02:00
30 changed files with 1839 additions and 117 deletions

View File

@@ -11,9 +11,9 @@ FROM node:22-alpine
WORKDIR /app
# Server-Dependencies installieren (better-sqlite3 braucht Build-Tools)
# Timezone support + Server-Dependencies (better-sqlite3 braucht Build-Tools)
COPY server/package*.json ./
RUN apk add --no-cache python3 make g++ && \
RUN apk add --no-cache tzdata python3 make g++ && \
npm ci --production && \
apk del python3 make g++

View File

@@ -21,6 +21,6 @@ You will receive a response within 48 hours. Once confirmed, a fix will be relea
## Scope
This policy covers the TREK application and its Docker image (`mauriceboe/nomad`).
This policy covers the TREK application and its Docker image (`mauriceboe/trek`).
Third-party dependencies are monitored via GitHub Dependabot.

5
chart/Chart.yaml Normal file
View File

@@ -0,0 +1,5 @@
apiVersion: v2
name: trek
version: 0.1.0
description: Minimal Helm chart for TREK app
appVersion: "latest"

33
chart/README.md Normal file
View File

@@ -0,0 +1,33 @@
# TREK Helm Chart
This is a minimal Helm chart for deploying the TREK app.
## Features
- Deploys the TREK container
- Exposes port 3000 via Service
- Optional persistent storage for `/app/data` and `/app/uploads`
- Configurable environment variables and secrets
- Optional generic Ingress support
- Health checks on `/api/health`
## Usage
```sh
helm install trek ./chart \
--set secretEnv.JWT_SECRET=your_jwt_secret \
--set ingress.enabled=true \
--set ingress.hosts[0].host=yourdomain.com
```
See `values.yaml` for more options.
## Files
- `Chart.yaml` — chart metadata
- `values.yaml` — configuration values
- `templates/` — Kubernetes manifests
## Notes
- Ingress is off by default. Enable and configure hosts for your domain.
- PVCs require a default StorageClass or specify one as needed.
- JWT_SECRET must be set for production use.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.

13
chart/templates/NOTES.txt Normal file
View File

@@ -0,0 +1,13 @@
1. JWT_SECRET handling:
- By default, the chart creates a secret with the value from `values.yaml: secretEnv.JWT_SECRET`.
- To generate a random JWT_SECRET at install, set `generateJwtSecret: true`.
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must have a key matching `existingSecretKey` (defaults to `JWT_SECRET`).
2. Example usage:
- Set a custom secret: `--set secretEnv.JWT_SECRET=your_secret`
- Generate a random secret: `--set generateJwtSecret=true`
- Use an existing secret: `--set existingSecret=my-k8s-secret`
- Use a custom key in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_KEY`
3. Only one method should be used at a time. If both `generateJwtSecret` and `existingSecret` are set, `existingSecret` takes precedence.
If using `existingSecret`, ensure the referenced secret and key exist in the target namespace.

View File

@@ -0,0 +1,18 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "trek.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "trek.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- printf "%s" $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}

View File

@@ -0,0 +1,12 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "trek.fullname" . }}-config
labels:
app: {{ include "trek.name" . }}
data:
NODE_ENV: {{ .Values.env.NODE_ENV | quote }}
PORT: {{ .Values.env.PORT | quote }}
{{- if .Values.env.ALLOWED_ORIGINS }}
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
{{- end }}

View File

@@ -0,0 +1,61 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "trek.fullname" . }}
labels:
app: {{ include "trek.name" . }}
spec:
replicas: 1
selector:
matchLabels:
app: {{ include "trek.name" . }}
template:
metadata:
labels:
app: {{ include "trek.name" . }}
spec:
{{- if .Values.imagePullSecrets }}
imagePullSecrets:
{{- range .Values.imagePullSecrets }}
- name: {{ .name }}
{{- end }}
{{- end }}
containers:
- name: trek
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: 3000
envFrom:
- configMapRef:
name: {{ include "trek.fullname" . }}-config
env:
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: {{ .Values.existingSecretKey | default "JWT_SECRET" }}
volumeMounts:
- name: data
mountPath: /app/data
- name: uploads
mountPath: /app/uploads
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 15
periodSeconds: 30
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
volumes:
- name: data
persistentVolumeClaim:
claimName: {{ include "trek.fullname" . }}-data
- name: uploads
persistentVolumeClaim:
claimName: {{ include "trek.fullname" . }}-uploads

View File

@@ -0,0 +1,32 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "trek.fullname" . }}
labels:
app: {{ include "trek.name" . }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.tls }}
tls:
{{- toYaml .Values.ingress.tls | nindent 4 }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host }}
http:
paths:
{{- range .paths }}
- path: {{ . }}
pathType: Prefix
backend:
service:
name: {{ include "trek.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}

25
chart/templates/pvc.yaml Normal file
View File

@@ -0,0 +1,25 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "trek.fullname" . }}-data
labels:
app: {{ include "trek.name" . }}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.persistence.data.size }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "trek.fullname" . }}-uploads
labels:
app: {{ include "trek.name" . }}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.persistence.uploads.size }}

View File

@@ -0,0 +1,23 @@
{{- if and (not .Values.existingSecret) (not .Values.generateJwtSecret) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "trek.fullname" . }}-secret
labels:
app: {{ include "trek.name" . }}
type: Opaque
data:
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ .Values.secretEnv.JWT_SECRET | b64enc | quote }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateJwtSecret) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "trek.fullname" . }}-secret
labels:
app: {{ include "trek.name" . }}
type: Opaque
stringData:
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ randAlphaNum 32 }}
{{- end }}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "trek.fullname" . }}
labels:
app: {{ include "trek.name" . }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: 3000
protocol: TCP
name: http
selector:
app: {{ include "trek.name" . }}

53
chart/values.yaml Normal file
View File

@@ -0,0 +1,53 @@
image:
repository: mauriceboe/trek
tag: latest
pullPolicy: IfNotPresent
# Optional image pull secrets for private registries
imagePullSecrets: []
# - name: my-registry-secret
service:
type: ClusterIP
port: 3000
env:
NODE_ENV: production
PORT: 3000
# ALLOWED_ORIGINS: ""
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
# JWT secret configuration
secretEnv:
# If set, use this value for JWT_SECRET (base64-encoded in secret.yaml)
JWT_SECRET: ""
# If true, a random JWT_SECRET will be generated during install (overrides secretEnv.JWT_SECRET)
generateJwtSecret: false
# If set, use an existing Kubernetes secret for JWT_SECRET
existingSecret: ""
existingSecretKey: JWT_SECRET
persistence:
enabled: true
data:
size: 1Gi
uploads:
size: 1Gi
resources: {}
ingress:
enabled: false
annotations: {}
hosts:
- host: chart-example.local
paths:
- /
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local

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

@@ -6,10 +6,15 @@ import type { HolidaysMap, VacayEntry } from '../../types'
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const WEEKDAYS_ES = ['Lu', 'Ma', 'Mi', 'Ju', 'Vi', 'Sa', 'Do']
const WEEKDAYS_FR = ['Lu', 'Ma', 'Me', 'Je', 'Ve', 'Sa', 'Di']
const WEEKDAYS_BR = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom']
const WEEKDAYS_AR = ['اث', 'ثل', 'أر', 'خم', 'جم', 'سب', 'أح']
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
const MONTHS_ES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']
const MONTHS_FR = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
const MONTHS_BR = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro']
const MONTHS_AR = ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر']
function hexToRgba(hex: string, alpha: number): string {
@@ -37,9 +42,10 @@ export default function VacayMonthCard({
onCellClick, companyMode, blockWeekends, weekendDays = [0, 6]
}: VacayMonthCardProps) {
const { language } = useTranslation()
const weekdays = language === 'de' ? WEEKDAYS_DE : language === 'es' ? WEEKDAYS_ES : language === 'ar' ? WEEKDAYS_AR : WEEKDAYS_EN
const monthNames = language === 'de' ? MONTHS_DE : language === 'es' ? MONTHS_ES : language === 'ar' ? MONTHS_AR : MONTHS_EN
const weekdays = language === 'de' ? WEEKDAYS_DE : language === 'es' ? WEEKDAYS_ES : language === 'fr' ? WEEKDAYS_FR : language === 'br' ? WEEKDAYS_BR : language === 'ar' ? WEEKDAYS_AR : WEEKDAYS_EN
const monthNames = language === 'de' ? MONTHS_DE : language === 'es' ? MONTHS_ES : language === 'fr' ? MONTHS_FR : language === 'br' ? MONTHS_BR : language === 'ar' ? MONTHS_AR : MONTHS_EN
const weeks = useMemo(() => {
const firstDay = new Date(year, month, 1)
const daysInMonth = new Date(year, month + 1, 0).getDate()

View File

@@ -8,6 +8,7 @@ import ru from './translations/ru'
import zh from './translations/zh'
import nl from './translations/nl'
import ar from './translations/ar'
import br from './translations/br'
type TranslationStrings = Record<string, string | { name: string; category: string }[]>
@@ -17,13 +18,14 @@ export const SUPPORTED_LANGUAGES = [
{ value: 'es', label: 'Español' },
{ value: 'fr', label: 'Français' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'br', label: 'Português (Brasil)' },
{ value: 'ru', label: 'Русский' },
{ value: 'zh', label: '中文' },
{ value: 'ar', label: 'العربية' },
] as const
const translations: Record<string, TranslationStrings> = { de, en, es, fr, ru, zh, nl, ar }
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA' }
const translations: Record<string, TranslationStrings> = { de, en, es, fr, ru, zh, nl, ar, br }
const LOCALES: Record<string, string> = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', ru: 'ru-RU', zh: 'zh-CN', nl: 'nl-NL', ar: 'ar-SA', br: 'pt-BR' }
const RTL_LANGUAGES = new Set(['ar'])
export function getLocaleForLanguage(language: string): string {
@@ -31,6 +33,7 @@ export function getLocaleForLanguage(language: string): string {
}
export function getIntlLanguage(language: string): string {
if (language === 'br') return 'pt-BR'
return ['de', 'es', 'fr', 'ru', 'zh', 'nl', 'ar'].includes(language) ? language : 'en'
}

File diff suppressed because it is too large Load Diff

View File

@@ -1022,7 +1022,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

@@ -1022,7 +1022,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

@@ -5,13 +5,13 @@ const fr: Record<string, string> = {
'common.delete': 'Supprimer',
'common.edit': 'Modifier',
'common.add': 'Ajouter',
'common.loading': 'Chargement...',
'common.loading': 'Chargement',
'common.error': 'Erreur',
'common.back': 'Retour',
'common.all': 'Tout',
'common.close': 'Fermer',
'common.open': 'Ouvrir',
'common.upload': 'Téléverser',
'common.upload': 'Importer',
'common.search': 'Rechercher',
'common.confirm': 'Confirmer',
'common.ok': 'OK',
@@ -24,10 +24,10 @@ const fr: Record<string, string> = {
'common.name': 'Nom',
'common.email': 'E-mail',
'common.password': 'Mot de passe',
'common.saving': 'Enregistrement...',
'common.saving': 'Enregistrement',
'common.update': 'Mettre à jour',
'common.change': 'Modifier',
'common.uploading': 'Téléversement…',
'common.uploading': 'Import en cours…',
'common.backToPlanning': 'Retour à la planification',
'common.reset': 'Réinitialiser',
@@ -44,7 +44,7 @@ const fr: Record<string, string> = {
// Dashboard
'dashboard.title': 'Mes voyages',
'dashboard.subtitle.loading': 'Chargement des voyages...',
'dashboard.subtitle.loading': 'Chargement des voyages',
'dashboard.subtitle.trips': '{count} voyages ({archived} archivés)',
'dashboard.subtitle.empty': 'Commencez votre premier voyage',
'dashboard.subtitle.activeOne': '{count} voyage actif',
@@ -54,8 +54,8 @@ const fr: Record<string, string> = {
'dashboard.gridView': 'Vue en grille',
'dashboard.listView': 'Vue en liste',
'dashboard.currency': 'Devise',
'dashboard.timezone': 'Fuseaux horaires',
'dashboard.localTime': 'Local',
'dashboard.timezone': 'Fuseau horaire',
'dashboard.localTime': 'Heure locale',
'dashboard.timezoneCustomTitle': 'Fuseau horaire personnalisé',
'dashboard.timezoneCustomLabelPlaceholder': 'Libellé (facultatif)',
'dashboard.timezoneCustomTzPlaceholder': 'ex. America/New_York',
@@ -105,7 +105,7 @@ const fr: Record<string, string> = {
'dashboard.addMembers': 'Compagnons de voyage',
'dashboard.addMember': 'Ajouter un membre',
'dashboard.coverSaved': 'Image de couverture enregistrée',
'dashboard.coverUploadError': 'Échec du téléversement',
'dashboard.coverUploadError': 'Échec de l\'import',
'dashboard.coverRemoveError': 'Échec de la suppression',
'dashboard.titleRequired': 'Le titre est obligatoire',
'dashboard.endDateError': 'La date de fin doit être postérieure à la date de début',
@@ -115,7 +115,7 @@ const fr: Record<string, string> = {
'settings.subtitle': 'Configurez vos paramètres personnels',
'settings.map': 'Carte',
'settings.mapTemplate': 'Modèle de carte',
'settings.mapTemplatePlaceholder.select': 'Sélectionner un modèle...',
'settings.mapTemplatePlaceholder.select': 'Sélectionner un modèle',
'settings.mapDefaultHint': 'Laissez vide pour OpenStreetMap (par défaut)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'Modèle d\'URL pour les tuiles de carte',
@@ -127,7 +127,7 @@ const fr: Record<string, string> = {
'settings.mapsKeyHint': 'Pour la recherche de lieux. Nécessite l\'API Places (New). Obtenez-la sur console.cloud.google.com',
'settings.weatherKey': 'Clé API OpenWeatherMap',
'settings.weatherKeyHint': 'Pour les données météo. Gratuit sur openweathermap.org/api',
'settings.keyPlaceholder': 'Saisir la clé...',
'settings.keyPlaceholder': 'Saisir la clé',
'settings.configured': 'Configuré',
'settings.saveKeys': 'Enregistrer les clés',
'settings.display': 'Affichage',
@@ -187,15 +187,15 @@ const fr: Record<string, string> = {
'settings.toast.keysSaved': 'Clés API enregistrées',
'settings.toast.displaySaved': 'Paramètres d\'affichage enregistrés',
'settings.toast.profileSaved': 'Profil enregistré',
'settings.uploadAvatar': 'Téléverser une photo de profil',
'settings.uploadAvatar': 'Importer une photo de profil',
'settings.removeAvatar': 'Supprimer la photo de profil',
'settings.avatarUploaded': 'Photo de profil mise à jour',
'settings.avatarRemoved': 'Photo de profil supprimée',
'settings.avatarError': 'Échec du téléversement',
'settings.avatarError': 'Échec de l\'import',
// Login
'login.error': 'Échec de la connexion. Veuillez vérifier vos identifiants.',
'login.tagline': 'Vos voyages.\nVotre plan.',
'login.tagline': 'Vos voyages.\nVotre organisation.',
'login.description': 'Planifiez vos voyages en collaboration avec des cartes interactives, des budgets et la synchronisation en temps réel.',
'login.features.maps': 'Cartes interactives',
'login.features.mapsDesc': 'Google Places, itinéraires et regroupement',
@@ -210,7 +210,7 @@ const fr: Record<string, string> = {
'login.features.bookings': 'Réservations',
'login.features.bookingsDesc': 'Vols, hôtels, restaurants et plus',
'login.features.files': 'Documents',
'login.features.filesDesc': 'Téléversez et gérez vos documents',
'login.features.filesDesc': 'Importez et gérez vos documents',
'login.features.routes': 'Itinéraires intelligents',
'login.features.routesDesc': 'Optimisation automatique et export Google Maps',
'login.selfHosted': 'Auto-hébergé · Open Source · Vos données restent les vôtres',
@@ -261,7 +261,7 @@ const fr: Record<string, string> = {
'register.minChars': 'Min. 6 caractères',
'register.confirmPassword': 'Confirmer le mot de passe',
'register.repeatPassword': 'Répéter le mot de passe',
'register.registering': 'Inscription en cours...',
'register.registering': 'Inscription en cours',
'register.register': 'S\'inscrire',
'register.hasAccount': 'Vous avez déjà un compte ?',
'register.signIn': 'Se connecter',
@@ -344,7 +344,7 @@ const fr: Record<string, string> = {
// File Types
'admin.fileTypes': 'Types de fichiers autorisés',
'admin.fileTypesHint': 'Configurez les types de fichiers que les utilisateurs peuvent téléverser.',
'admin.fileTypesHint': 'Configurez les types de fichiers que les utilisateurs peuvent importer.',
'admin.fileTypesFormat': 'Extensions séparées par des virgules (ex. jpg,png,pdf,doc). Utilisez * pour autoriser tous les types.',
'admin.fileTypesSaved': 'Paramètres des types de fichiers enregistrés',
@@ -382,11 +382,11 @@ const fr: Record<string, string> = {
'admin.addons.catalog.budget.description': 'Suivez les dépenses et planifiez votre budget de voyage',
'admin.addons.catalog.documents.name': 'Documents',
'admin.addons.catalog.documents.description': 'Stockez et gérez vos documents de voyage',
'admin.addons.catalog.vacay.name': 'Vacay',
'admin.addons.catalog.vacay.name': 'Vacances',
'admin.addons.catalog.vacay.description': 'Planificateur de vacances personnel avec vue calendrier',
'admin.addons.catalog.atlas.name': 'Atlas',
'admin.addons.catalog.atlas.description': 'Carte du monde avec pays visités et statistiques de voyage',
'admin.addons.catalog.collab.name': 'Collab',
'admin.addons.catalog.collab.name': 'Collaboration',
'admin.addons.catalog.collab.description': 'Notes en temps réel, sondages et chat pour la planification de voyage',
'admin.addons.subtitleBefore': 'Activez ou désactivez des fonctionnalités pour personnaliser votre expérience ',
'admin.addons.subtitleAfter': '.',
@@ -409,7 +409,7 @@ const fr: Record<string, string> = {
'admin.weather.climateDesc': 'Moyennes des 85 dernières années pour les jours au-delà des prévisions de 16 jours',
'admin.weather.requests': '10 000 requêtes / jour',
'admin.weather.requestsDesc': 'Gratuit, aucune clé API requise',
'admin.weather.locationHint': 'La météo est basée sur le premier lieu avec des coordonnées de chaque jour. Si aucun lieu n\'est assigné à un jour, un lieu de la liste est utilisé comme référence.',
'admin.weather.locationHint': 'La météo est basée sur le premier lieu avec des coordonnées de chaque jour. Si aucun lieu n\'est attribué à un jour, un lieu de la liste est utilisé comme référence.',
// GitHub
'admin.tabs.github': 'GitHub',
@@ -420,8 +420,8 @@ const fr: Record<string, string> = {
'admin.github.showDetails': 'Afficher les détails',
'admin.github.hideDetails': 'Masquer les détails',
'admin.github.loadMore': 'Charger plus',
'admin.github.loading': 'Chargement...',
'admin.github.support': 'Aide à continuer le développement de TREK',
'admin.github.loading': 'Chargement',
'admin.github.support': 'Aidez à poursuivre le développement de TREK',
'admin.github.error': 'Impossible de charger les versions',
'admin.github.by': 'par',
@@ -431,7 +431,7 @@ const fr: Record<string, string> = {
'admin.update.install': 'Installer la mise à jour',
'admin.update.confirmTitle': 'Installer la mise à jour ?',
'admin.update.confirmText': 'TREK sera mis à jour de {current} vers {version}. Le serveur redémarrera automatiquement ensuite.',
'admin.update.dataInfo': 'Toutes vos données (voyages, utilisateurs, clés API, téléversements, Vacay, Atlas, budgets) seront préservées.',
'admin.update.dataInfo': 'Toutes vos données (voyages, utilisateurs, clés API, importations, Vacances, Atlas, budgets) seront préservées.',
'admin.update.warning': 'L\'application sera brièvement indisponible pendant le redémarrage.',
'admin.update.confirm': 'Mettre à jour maintenant',
'admin.update.installing': 'Mise à jour…',
@@ -444,7 +444,7 @@ const fr: Record<string, string> = {
'admin.update.reloadHint': 'Veuillez recharger la page dans quelques secondes.',
// Vacay addon
'vacay.subtitle': 'Planifiez et gérez vos jours de congé',
'vacay.subtitle': 'Planifiez et gérez vos jours de congés',
'vacay.settings': 'Paramètres',
'vacay.year': 'Année',
'vacay.addYear': 'Ajouter une année',
@@ -474,6 +474,14 @@ const fr: Record<string, string> = {
'vacay.used': 'Utilisés',
'vacay.remaining': 'Restants',
'vacay.carriedOver': 'de {year}',
'vacay.weekendDays': 'Jours de week-end',
'vacay.mon': 'Lun',
'vacay.tue': 'Mar',
'vacay.wed': 'Mer',
'vacay.thu': 'Jeu',
'vacay.fri': 'Ven',
'vacay.sat': 'Sam',
'vacay.sun': 'Dim',
'vacay.blockWeekends': 'Bloquer les week-ends',
'vacay.blockWeekendsHint': 'Empêcher les entrées de vacances les samedis et dimanches',
'vacay.weekendDays': 'Jours de week-end',
@@ -499,11 +507,11 @@ const fr: Record<string, string> = {
'vacay.shareEmailPlaceholder': 'E-mail de l\'utilisateur TREK',
'vacay.shareSuccess': 'Plan partagé avec succès',
'vacay.shareError': 'Impossible de partager le plan',
'vacay.dissolve': 'Dissoudre la fusion',
'vacay.dissolve': 'Séparer les calendriers',
'vacay.dissolveHint': 'Séparer à nouveau les calendriers. Vos entrées seront conservées.',
'vacay.dissolveAction': 'Dissoudre',
'vacay.dissolved': 'Calendrier séparé',
'vacay.fusedWith': 'Fusionné avec',
'vacay.fusedWith': 'Partagé avec',
'vacay.you': 'vous',
'vacay.noData': 'Aucune donnée',
'vacay.changeColor': 'Changer la couleur',
@@ -589,14 +597,14 @@ const fr: Record<string, string> = {
'trip.tabs.packingShort': 'Bagages',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Fichiers',
'trip.loading': 'Chargement du voyage...',
'trip.loading': 'Chargement du voyage',
'trip.mobilePlan': 'Plan',
'trip.mobilePlaces': 'Lieux',
'trip.toast.placeUpdated': 'Lieu mis à jour',
'trip.toast.placeAdded': 'Lieu ajouté',
'trip.toast.placeDeleted': 'Lieu supprimé',
'trip.toast.selectDay': 'Veuillez d\'abord sélectionner un jour',
'trip.toast.assignedToDay': 'Lieu assigné au jour',
'trip.toast.assignedToDay': 'Lieu attribué au planning',
'trip.toast.reorderError': 'Échec de la réorganisation',
'trip.toast.reservationUpdated': 'Réservation mise à jour',
'trip.toast.reservationAdded': 'Réservation ajoutée',
@@ -614,7 +622,7 @@ const fr: Record<string, string> = {
'dayplan.totalCost': 'Coût total',
'dayplan.days': 'Jours',
'dayplan.dayN': 'Jour {n}',
'dayplan.calculating': 'Calcul en cours...',
'dayplan.calculating': 'Calcul en cours',
'dayplan.route': 'Itinéraire',
'dayplan.optimize': 'Optimiser',
'dayplan.optimized': 'Itinéraire optimisé',
@@ -642,7 +650,7 @@ const fr: Record<string, string> = {
'places.assignToDay': 'Ajouter à quel jour ?',
'places.all': 'Tous',
'places.unplanned': 'Non planifiés',
'places.search': 'Rechercher des lieux...',
'places.search': 'Rechercher des lieux',
'places.allCategories': 'Toutes les catégories',
'places.count': '{count} lieux',
'places.countSingular': '1 lieu',
@@ -652,7 +660,7 @@ const fr: Record<string, string> = {
'places.formName': 'Nom',
'places.formNamePlaceholder': 'ex. Tour Eiffel',
'places.formDescription': 'Description',
'places.formDescriptionPlaceholder': 'Brève description...',
'places.formDescriptionPlaceholder': 'Brève description',
'places.formAddress': 'Adresse',
'places.formAddressPlaceholder': 'Rue, ville, pays',
'places.formLat': 'Latitude (ex. 48.8566)',
@@ -666,10 +674,10 @@ const fr: Record<string, string> = {
'places.endTimeBeforeStart': 'L\'heure de fin est antérieure à l\'heure de début',
'places.timeCollision': 'Chevauchement horaire avec :',
'places.formWebsite': 'Site web',
'places.formNotesPlaceholder': 'Notes personnelles...',
'places.formNotesPlaceholder': 'Notes personnelles',
'places.formReservation': 'Réservation',
'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation...',
'places.mapsSearchPlaceholder': 'Rechercher des lieux...',
'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation',
'places.mapsSearchPlaceholder': 'Rechercher des lieux',
'places.mapsSearchError': 'La recherche de lieu a échoué.',
'places.osmHint': 'Recherche via OpenStreetMap (pas de photos, horaires ni notes). Ajoutez une clé API Google dans les paramètres pour plus de détails.',
'places.osmActive': 'Recherche via OpenStreetMap (pas de photos, notes ni horaires). Ajoutez une clé API Google dans les paramètres pour des données enrichies.',
@@ -714,7 +722,7 @@ const fr: Record<string, string> = {
'reservations.time': 'Heure',
'reservations.timeAlt': 'Heure (alternative, ex. 19h30)',
'reservations.notes': 'Notes',
'reservations.notesPlaceholder': 'Notes supplémentaires...',
'reservations.notesPlaceholder': 'Notes supplémentaires',
'reservations.meta.airline': 'Compagnie aérienne',
'reservations.meta.flightNumber': 'N° de vol',
'reservations.meta.from': 'De',
@@ -746,14 +754,14 @@ const fr: Record<string, string> = {
'reservations.confirm.deleteBody': '« {name} » sera définitivement supprimé.',
'reservations.toast.updated': 'Réservation mise à jour',
'reservations.toast.removed': 'Réservation supprimée',
'reservations.toast.fileUploaded': 'Fichier téléversé',
'reservations.toast.uploadError': 'Échec du téléversement',
'reservations.toast.fileUploaded': 'Fichier importé',
'reservations.toast.uploadError': 'Échec de l\'import',
'reservations.newTitle': 'Nouvelle réservation',
'reservations.bookingType': 'Type de réservation',
'reservations.titleLabel': 'Titre',
'reservations.titlePlaceholder': 'ex. Lufthansa LH123, Hôtel Adlon, ...',
'reservations.titlePlaceholder': 'ex. Lufthansa LH123, Hôtel Adlon, ',
'reservations.locationAddress': 'Lieu / Adresse',
'reservations.locationPlaceholder': 'Adresse, aéroport, hôtel...',
'reservations.locationPlaceholder': 'Adresse, aéroport, hôtel',
'reservations.confirmationCode': 'Code de réservation',
'reservations.confirmationPlaceholder': 'ex. ABC12345',
'reservations.day': 'Jour',
@@ -761,22 +769,22 @@ const fr: Record<string, string> = {
'reservations.place': 'Lieu',
'reservations.noPlace': 'Aucun lieu',
'reservations.pendingSave': 'sera enregistré…',
'reservations.uploading': 'Téléversement...',
'reservations.uploading': 'Importation…',
'reservations.attachFile': 'Joindre un fichier',
'reservations.linkExisting': 'Lier un fichier existant',
'reservations.toast.saveError': 'Échec de l\'enregistrement',
'reservations.toast.updateError': 'Échec de la mise à jour',
'reservations.toast.deleteError': 'Échec de la suppression',
'reservations.confirm.remove': 'Supprimer la réservation pour « {name} » ?',
'reservations.linkAssignment': 'Lier à l\'assignation du jour',
'reservations.pickAssignment': 'Sélectionnez une assignation de votre plan...',
'reservations.linkAssignment': 'Lier à l\'affectation du jour',
'reservations.pickAssignment': 'Sélectionnez une affectation de votre plan',
'reservations.noAssignment': 'Aucun lien (autonome)',
// Budget
'budget.title': 'Budget',
'budget.emptyTitle': 'Aucun budget créé',
'budget.emptyText': 'Créez des catégories et des entrées pour planifier votre budget de voyage',
'budget.emptyPlaceholder': 'Nom de la catégorie...',
'budget.emptyPlaceholder': 'Nom de la catégorie',
'budget.createCategory': 'Créer une catégorie',
'budget.category': 'Catégorie',
'budget.categoryName': 'Nom de la catégorie',
@@ -794,7 +802,7 @@ const fr: Record<string, string> = {
'budget.total': 'Total',
'budget.totalBudget': 'Budget total',
'budget.byCategory': 'Par catégorie',
'budget.editTooltip': 'Cliquer pour modifier',
'budget.editTooltip': 'Cliquez pour modifier',
'budget.confirm.deleteCategory': 'Voulez-vous vraiment supprimer la catégorie « {name} » avec {count} entrées ?',
'budget.deleteCategory': 'Supprimer la catégorie',
'budget.perPerson': 'Par personne',
@@ -809,12 +817,12 @@ const fr: Record<string, string> = {
'files.title': 'Fichiers',
'files.count': '{count} fichiers',
'files.countSingular': '1 fichier',
'files.uploaded': '{count} téléversés',
'files.uploadError': 'Échec du téléversement',
'files.uploaded': '{count} importés',
'files.uploadError': 'Échec de l\'import',
'files.dropzone': 'Déposez les fichiers ici',
'files.dropzoneHint': 'ou cliquez pour parcourir',
'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 Mo',
'files.uploading': 'Téléversement...',
'files.uploading': 'Importation…',
'files.filterAll': 'Tous',
'files.filterPdf': 'PDF',
'files.filterImages': 'Images',
@@ -822,7 +830,7 @@ const fr: Record<string, string> = {
'files.filterCollab': 'Notes Collab',
'files.sourceCollab': 'Depuis les notes Collab',
'files.empty': 'Aucun fichier',
'files.emptyHint': 'Téléversez des fichiers pour les joindre à votre voyage',
'files.emptyHint': 'Importez des fichiers pour les joindre à votre voyage',
'files.openTab': 'Ouvrir dans un nouvel onglet',
'files.confirm.delete': 'Voulez-vous vraiment supprimer ce fichier ?',
'files.toast.deleted': 'Fichier supprimé',
@@ -841,18 +849,18 @@ const fr: Record<string, string> = {
'files.assignTitle': 'Assigner le fichier',
'files.assignPlace': 'Lieu',
'files.assignBooking': 'Réservation',
'files.unassigned': 'Non assigné',
'files.unassigned': 'Non attribué',
'files.unlink': 'Supprimer le lien',
'files.toast.trashed': 'Déplacé dans la corbeille',
'files.toast.restored': 'Fichier restauré',
'files.toast.trashEmptied': 'Corbeille vidée',
'files.toast.assigned': 'Fichier assigné',
'files.toast.assigned': 'Fichier attribué',
'files.toast.assignError': 'Échec de l\'assignation',
'files.toast.restoreError': 'Échec de la restauration',
'files.confirm.permanentDelete': 'Supprimer définitivement ce fichier ? Cette action est irréversible.',
'files.confirm.emptyTrash': 'Supprimer définitivement tous les fichiers de la corbeille ? Cette action est irréversible.',
'files.noteLabel': 'Note',
'files.notePlaceholder': 'Ajouter une note...',
'files.notePlaceholder': 'Ajouter une note',
// Packing
'packing.title': 'Liste de bagages',
@@ -873,8 +881,8 @@ const fr: Record<string, string> = {
'packing.suggestionsTitle': 'Ajouter des suggestions',
'packing.allSuggested': 'Toutes les suggestions ajoutées',
'packing.allPacked': 'Tout est emballé !',
'packing.addPlaceholder': 'Ajouter un nouvel article...',
'packing.categoryPlaceholder': 'Catégorie...',
'packing.addPlaceholder': 'Ajouter un nouvel article',
'packing.categoryPlaceholder': 'Catégorie',
'packing.filterAll': 'Tous',
'packing.filterOpen': 'À faire',
'packing.filterDone': 'Fait',
@@ -988,10 +996,10 @@ const fr: Record<string, string> = {
// Backup (Admin)
'backup.title': 'Sauvegarde des données',
'backup.subtitle': 'Base de données et tous les fichiers téléversés',
'backup.subtitle': 'Base de données et tous les fichiers importés',
'backup.refresh': 'Actualiser',
'backup.upload': 'Téléverser une sauvegarde',
'backup.uploading': 'Téléversement…',
'backup.upload': 'Importer une sauvegarde',
'backup.uploading': 'Importation…',
'backup.create': 'Créer une sauvegarde',
'backup.creating': 'Création…',
'backup.empty': 'Aucune sauvegarde',
@@ -999,14 +1007,14 @@ const fr: Record<string, string> = {
'backup.download': 'Télécharger',
'backup.restore': 'Restaurer',
'backup.confirm.restore': 'Restaurer la sauvegarde « {name} » ?\n\nToutes les données actuelles seront remplacées par la sauvegarde.',
'backup.confirm.uploadRestore': 'Téléverser et restaurer le fichier de sauvegarde « {name} » ?\n\nToutes les données actuelles seront écrasées.',
'backup.confirm.uploadRestore': 'Importer et restaurer le fichier de sauvegarde « {name} » ?\n\nToutes les données actuelles seront écrasées.',
'backup.confirm.delete': 'Supprimer la sauvegarde « {name} » ?',
'backup.toast.loadError': 'Impossible de charger les sauvegardes',
'backup.toast.created': 'Sauvegarde créée avec succès',
'backup.toast.createError': 'Impossible de créer la sauvegarde',
'backup.toast.restored': 'Sauvegarde restaurée. La page va se recharger…',
'backup.toast.restoreError': 'Échec de la restauration',
'backup.toast.uploadError': 'Échec du téléversement',
'backup.toast.uploadError': 'Échec de l\'import',
'backup.toast.deleted': 'Sauvegarde supprimée',
'backup.toast.deleteError': 'Échec de la suppression',
'backup.toast.downloadError': 'Échec du téléchargement',
@@ -1032,15 +1040,15 @@ const fr: Record<string, string> = {
// Photos
'photos.allDays': 'Tous les jours',
'photos.noPhotos': 'Aucune photo',
'photos.uploadHint': 'Téléversez vos photos de voyage',
'photos.uploadHint': 'Importez vos photos de voyage',
'photos.clickToSelect': 'ou cliquez pour sélectionner',
'photos.linkPlace': 'Lier au lieu',
'photos.noPlace': 'Aucun lieu',
'photos.uploadN': '{n} photo(s) téléversées',
'photos.uploadN': '{n} photo(s) importée(s)',
// Backup restore modal
'backup.restoreConfirmTitle': 'Restaurer la sauvegarde ?',
'backup.restoreWarning': 'Toutes les données actuelles (voyages, lieux, utilisateurs, téléversements) seront définitivement remplacées par la sauvegarde. Cette action est irréversible.',
'backup.restoreWarning': 'Toutes les données actuelles (voyages, lieux, utilisateurs, importations) seront définitivement remplacées par la sauvegarde. Cette action est irréversible.',
'backup.restoreTip': 'Conseil : créez une sauvegarde de l\'état actuel avant de restaurer.',
'backup.restoreConfirm': 'Oui, restaurer',
@@ -1077,8 +1085,8 @@ const fr: Record<string, string> = {
'planner.placeN': '{n} lieux',
'planner.addNote': 'Ajouter une note',
'planner.noEntries': 'Aucune entrée pour ce jour',
'planner.addPlace': 'Ajouter un lieu/activité',
'planner.addPlaceShort': '+ Ajouter un lieu/activité',
'planner.addPlace': 'Ajouter un lieu ou une activité',
'planner.addPlaceShort': '+ Ajouter un lieu ou une activité',
'planner.resPending': 'Réservation en attente · ',
'planner.resConfirmed': 'Réservation confirmée · ',
'planner.notePlaceholder': 'Note…',
@@ -1108,7 +1116,7 @@ const fr: Record<string, string> = {
'planner.noDays': 'Aucun jour',
'planner.editTripToAddDays': 'Modifiez le voyage pour ajouter des jours',
'planner.dayCount': '{n} jours',
'planner.clickToUnlock': 'Cliquer pour déverrouiller',
'planner.clickToUnlock': 'Cliquez pour déverrouiller',
'planner.keepPosition': 'Maintenir la position lors de l\'optimisation de l\'itinéraire',
'planner.dayDetails': 'Détails du jour',
'planner.dayN': 'Jour {n}',
@@ -1184,7 +1192,7 @@ const fr: Record<string, string> = {
'memories.confirmShareButton': 'Partager les photos',
// Collab Addon
'collab.tabs.chat': 'Chat',
'collab.tabs.chat': 'Discussion',
'collab.tabs.notes': 'Notes',
'collab.tabs.polls': 'Sondages',
'collab.whatsNext.title': 'À venir',
@@ -1194,7 +1202,7 @@ const fr: Record<string, string> = {
'collab.whatsNext.until': 'à',
'collab.whatsNext.emptyHint': 'Les activités avec des horaires apparaîtront ici',
'collab.chat.send': 'Envoyer',
'collab.chat.placeholder': 'Écrire un message...',
'collab.chat.placeholder': 'Écrire un message',
'collab.chat.empty': 'Commencez la conversation',
'collab.chat.emptyHint': 'Les messages sont partagés avec tous les membres du voyage',
'collab.chat.emptyDesc': 'Partagez des idées, des plans et des mises à jour avec votre groupe de voyage',
@@ -1211,9 +1219,9 @@ const fr: Record<string, string> = {
'collab.notes.emptyHint': 'Commencez à capturer vos idées et plans',
'collab.notes.all': 'Toutes',
'collab.notes.titlePlaceholder': 'Titre de la note',
'collab.notes.contentPlaceholder': 'Écrivez quelque chose...',
'collab.notes.contentPlaceholder': 'Écrivez quelque chose',
'collab.notes.categoryPlaceholder': 'Catégorie',
'collab.notes.newCategory': 'Nouvelle catégorie...',
'collab.notes.newCategory': 'Nouvelle catégorie',
'collab.notes.category': 'Catégorie',
'collab.notes.noCategory': 'Sans catégorie',
'collab.notes.color': 'Couleur',
@@ -1227,7 +1235,7 @@ const fr: Record<string, string> = {
'collab.notes.categorySettings': 'Gérer les catégories',
'collab.notes.create': 'Créer',
'collab.notes.website': 'Site web',
'collab.notes.websitePlaceholder': 'https://...',
'collab.notes.websitePlaceholder': 'https://',
'collab.notes.attachFiles': 'Joindre des fichiers',
'collab.notes.noCategoriesYet': 'Aucune catégorie',
'collab.notes.emptyDesc': 'Créez une note pour commencer',

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

@@ -280,6 +280,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 {

View File

@@ -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

View File

@@ -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 });
});

View File

@@ -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<string, unknown> {
const {
password_hash: _p,
@@ -39,6 +44,9 @@ function stripUserForClient(user: User): Record<string, unknown> {
} = 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',
});
});

View File

@@ -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<string, unknown>): {
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<string, unknown>): {
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) => {

View File

@@ -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<string, string> = {
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