diff --git a/public/i18n.js b/public/i18n.js index ee3f587..b45a0fe 100644 --- a/public/i18n.js +++ b/public/i18n.js @@ -9,7 +9,10 @@ const SUPPORTED_LOCALES = ['de', 'en', 'es', 'fr', 'it', 'sv', 'el', 'ru', 'tr', const DEFAULT_LOCALE = 'de'; const STORAGE_KEY = 'oikos-locale'; const DATE_FORMAT_KEY = 'oikos-date-format'; +const TIME_FORMAT_KEY = 'oikos-time-format'; const DEFAULT_DATE_FORMAT = 'dmy'; +const DEFAULT_TIME_FORMAT = '24h'; +const VALID_TIME_FORMATS = ['24h', '12h']; let currentLocale = DEFAULT_LOCALE; let translations = {}; @@ -93,6 +96,15 @@ export function getDateFormat() { return getDateFormatPreference(); } +function getTimeFormatPreference() { + const stored = localStorage.getItem(TIME_FORMAT_KEY); + return VALID_TIME_FORMATS.includes(stored) ? stored : DEFAULT_TIME_FORMAT; +} + +export function getTimeFormat() { + return getTimeFormatPreference(); +} + function formatDateParts(date, useUtc = false) { const d = date instanceof Date ? date : new Date(date); if (isNaN(d.getTime())) return ''; @@ -176,9 +188,78 @@ export function formatTime(date) { if (date == null) return ''; const d = date instanceof Date ? date : new Date(date); if (isNaN(d.getTime())) return ''; + if (getTimeFormatPreference() === '12h') { + const hour = d.getHours(); + const minute = String(d.getMinutes()).padStart(2, '0'); + const displayHour = hour % 12 || 12; + return `${displayHour}:${minute} ${hour >= 12 ? 'PM' : 'AM'}`; + } return new Intl.DateTimeFormat(currentLocale, { hour: '2-digit', minute: '2-digit', hour12: false, }).format(d); } + +function toTimeParts(value) { + if (value == null || value === '') return null; + + if (value instanceof Date) { + if (isNaN(value.getTime())) return null; + return { hour: value.getHours(), minute: value.getMinutes() }; + } + + const raw = String(value).trim(); + if (!raw) return null; + + if (/^\d{1,2}:\d{2}$/.test(raw)) { + const [hour, minute] = raw.split(':').map(Number); + if (hour >= 0 && hour < 24 && minute >= 0 && minute < 60) { + return { hour, minute }; + } + return null; + } + + const ampmMatch = raw.match(/^(\d{1,2})(?::(\d{2}))?\s*([ap]m)$/i); + if (ampmMatch) { + let hour = Number(ampmMatch[1]); + const minute = Number(ampmMatch[2] ?? 0); + const meridiem = ampmMatch[3].toLowerCase(); + if (!Number.isInteger(hour) || !Number.isInteger(minute) || minute < 0 || minute >= 60) return null; + if (hour < 1 || hour > 12) return null; + if (meridiem === 'pm' && hour !== 12) hour += 12; + if (meridiem === 'am' && hour === 12) hour = 0; + return { hour, minute }; + } + + return null; +} + +export function formatTimeInput(value) { + const parts = toTimeParts(value); + if (!parts) return ''; + const hour = String(parts.hour).padStart(2, '0'); + const minute = String(parts.minute).padStart(2, '0'); + if (getTimeFormatPreference() === '12h') { + const isPm = parts.hour >= 12; + const displayHour = parts.hour % 12 || 12; + return `${displayHour}:${minute} ${isPm ? 'PM' : 'AM'}`; + } + return `${hour}:${minute}`; +} + +export function parseTimeInput(value) { + const raw = String(value || '').trim(); + if (!raw) return ''; + const parts = toTimeParts(raw); + if (!parts) return ''; + return `${String(parts.hour).padStart(2, '0')}:${String(parts.minute).padStart(2, '0')}`; +} + +export function isTimeInputValid(value) { + return !String(value || '').trim() || !!parseTimeInput(value); +} + +export function timeInputPlaceholder() { + return getTimeFormatPreference() === '12h' ? 'h:mm AM/PM' : 'HH:MM'; +} diff --git a/public/locales/ar.json b/public/locales/ar.json index 9dc25ef..e247dd4 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -607,6 +607,9 @@ "dateFormatLabel": "تنسيق التاريخ المفضل", "dateFormatHint": "اختر كيف تظهر التواريخ في التطبيق.", "dateFormatSavedToast": "تم حفظ تنسيق التاريخ.", + "timeFormatLabel": "تنسيق الوقت", + "timeFormatHours": "ساعة", + "timeFormatSavedToast": "تم حفظ تنسيق الوقت.", "themeSystem": "النظام", "themeSysLabel": "استخدام إعداد النظام", "themeLight": "فاتح", diff --git a/public/locales/de.json b/public/locales/de.json index 09be1e2..aec8962 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -632,6 +632,9 @@ "dateFormatLabel": "Bevorzugtes Datumsformat", "dateFormatHint": "Wähle, wie Daten in der App angezeigt werden.", "dateFormatSavedToast": "Datumsformat gespeichert.", + "timeFormatLabel": "Zeitformat", + "timeFormatHours": "Stunden", + "timeFormatSavedToast": "Zeitformat gespeichert.", "themeSystem": "System", "themeSysLabel": "System-Einstellung verwenden", "themeLight": "Hell", diff --git a/public/locales/el.json b/public/locales/el.json index 3cd0e17..e3230bb 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -607,6 +607,9 @@ "dateFormatLabel": "Προτιμώμενη μορφή ημερομηνίας", "dateFormatHint": "Επιλέξτε πώς εμφανίζονται οι ημερομηνίες στην εφαρμογή.", "dateFormatSavedToast": "Η μορφή ημερομηνίας αποθηκεύτηκε.", + "timeFormatLabel": "Μορφή ώρας", + "timeFormatHours": "ώρες", + "timeFormatSavedToast": "Η μορφή ώρας αποθηκεύτηκε.", "themeSystem": "Σύστημα", "themeSysLabel": "Χρήση ρύθμισης συστήματος", "themeLight": "Ανοιχτό", diff --git a/public/locales/en.json b/public/locales/en.json index b9d3157..21462d9 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -607,6 +607,9 @@ "dateFormatLabel": "Preferred date format", "dateFormatHint": "Choose how dates are displayed throughout the app.", "dateFormatSavedToast": "Date format saved.", + "timeFormatLabel": "Time format", + "timeFormatHours": "hours", + "timeFormatSavedToast": "Time format saved.", "themeSystem": "System", "themeSysLabel": "Use system setting", "themeLight": "Light", @@ -1019,4 +1022,4 @@ "dropzoneHint": "Drag a file into this area, or use the file picker.", "selectedFileLabel": "Selected: {{name}}" } -} \ No newline at end of file +} diff --git a/public/locales/es.json b/public/locales/es.json index 5410d31..6c4ca09 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -607,6 +607,9 @@ "dateFormatLabel": "Formato de fecha preferido", "dateFormatHint": "Elige cómo se muestran las fechas en toda la app.", "dateFormatSavedToast": "Formato de fecha guardado.", + "timeFormatLabel": "Formato de hora", + "timeFormatHours": "horas", + "timeFormatSavedToast": "Formato de hora guardado.", "themeSystem": "Sistema", "themeSysLabel": "Usar configuración del sistema", "themeLight": "Claro", diff --git a/public/locales/fr.json b/public/locales/fr.json index 9641f44..d804651 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -607,6 +607,9 @@ "dateFormatLabel": "Format de date préféré", "dateFormatHint": "Choisissez comment les dates sont affichées dans l'application.", "dateFormatSavedToast": "Format de date enregistré.", + "timeFormatLabel": "Format de l'heure", + "timeFormatHours": "heures", + "timeFormatSavedToast": "Format de l'heure enregistré.", "themeSystem": "Système", "themeSysLabel": "Utiliser le paramètre système", "themeLight": "Clair", diff --git a/public/locales/hi.json b/public/locales/hi.json index e64aa80..028f1a1 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -607,6 +607,9 @@ "dateFormatLabel": "पसंदीदा तारीख प्रारूप", "dateFormatHint": "चुनें कि ऐप में तारीखें कैसे दिखाई दें।", "dateFormatSavedToast": "तारीख प्रारूप सहेजा गया।", + "timeFormatLabel": "समय प्रारूप", + "timeFormatHours": "घंटे", + "timeFormatSavedToast": "समय प्रारूप सहेजा गया।", "themeSystem": "सिस्टम", "themeSysLabel": "सिस्टम सेटिंग का उपयोग करें", "themeLight": "हल्का", diff --git a/public/locales/it.json b/public/locales/it.json index 4bb0782..f8dceed 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -607,6 +607,9 @@ "dateFormatLabel": "Formato data preferito", "dateFormatHint": "Scegli come vengono mostrate le date nell'app.", "dateFormatSavedToast": "Formato data salvato.", + "timeFormatLabel": "Formato orario", + "timeFormatHours": "ore", + "timeFormatSavedToast": "Formato orario salvato.", "themeSystem": "Sistema", "themeSysLabel": "Usa impostazione di sistema", "themeLight": "Chiaro", diff --git a/public/locales/ja.json b/public/locales/ja.json index 2dc64a4..fa047db 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -607,6 +607,9 @@ "dateFormatLabel": "希望する日付形式", "dateFormatHint": "アプリ内で日付をどう表示するかを選択します。", "dateFormatSavedToast": "日付形式を保存しました。", + "timeFormatLabel": "時刻形式", + "timeFormatHours": "時間", + "timeFormatSavedToast": "時刻形式を保存しました。", "themeSystem": "システム設定", "themeSysLabel": "システム設定を使用", "themeLight": "ライト", diff --git a/public/locales/pt.json b/public/locales/pt.json index 612b633..32b1e7f 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -607,6 +607,9 @@ "dateFormatLabel": "Formato preferido da data", "dateFormatHint": "Escolha como as datas aparecem em toda a aplicação.", "dateFormatSavedToast": "Formato da data salvo.", + "timeFormatLabel": "Formato de hora", + "timeFormatHours": "horas", + "timeFormatSavedToast": "Formato de hora salvo.", "themeSystem": "Sistema", "themeSysLabel": "Usar configuração do sistema", "themeLight": "Claro", diff --git a/public/locales/ru.json b/public/locales/ru.json index 60e5983..8de0150 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -607,6 +607,9 @@ "dateFormatLabel": "Предпочитаемый формат даты", "dateFormatHint": "Выберите, как даты отображаются в приложении.", "dateFormatSavedToast": "Формат даты сохранён.", + "timeFormatLabel": "Формат времени", + "timeFormatHours": "часов", + "timeFormatSavedToast": "Формат времени сохранён.", "themeSystem": "Система", "themeSysLabel": "Использовать системную настройку", "themeLight": "Светлая", diff --git a/public/locales/sv.json b/public/locales/sv.json index 27b17e5..37a2198 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -607,6 +607,9 @@ "dateFormatLabel": "Önskat datumformat", "dateFormatHint": "Välj hur datum visas i appen.", "dateFormatSavedToast": "Datumformat sparat.", + "timeFormatLabel": "Tidsformat", + "timeFormatHours": "timmar", + "timeFormatSavedToast": "Tidsformat sparat.", "themeSystem": "System", "themeSysLabel": "Använd systeminställning", "themeLight": "Ljus", diff --git a/public/locales/tr.json b/public/locales/tr.json index 8cda108..e0e9dbd 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -607,6 +607,9 @@ "dateFormatLabel": "Tercih edilen tarih biçimi", "dateFormatHint": "Tarihlerin uygulamada nasıl görüneceğini seçin.", "dateFormatSavedToast": "Tarih biçimi kaydedildi.", + "timeFormatLabel": "Saat biçimi", + "timeFormatHours": "saat", + "timeFormatSavedToast": "Saat biçimi kaydedildi.", "themeSystem": "Sistem", "themeSysLabel": "Sistem ayarını kullan", "themeLight": "Açık", diff --git a/public/locales/uk.json b/public/locales/uk.json index 272cde9..7d97282 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -607,6 +607,9 @@ "dateFormatLabel": "Бажаний формат дати", "dateFormatHint": "Виберіть, як дати відображаються в застосунку.", "dateFormatSavedToast": "Формат дати збережено.", + "timeFormatLabel": "Формат часу", + "timeFormatHours": "годин", + "timeFormatSavedToast": "Формат часу збережено.", "themeSystem": "Системна", "themeSysLabel": "Використовувати системні налаштування", "themeLight": "Світла", diff --git a/public/locales/zh.json b/public/locales/zh.json index e0ef6e8..babffb6 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -607,6 +607,9 @@ "dateFormatLabel": "首选日期格式", "dateFormatHint": "选择日期在应用中的显示方式。", "dateFormatSavedToast": "日期格式已保存。", + "timeFormatLabel": "时间格式", + "timeFormatHours": "小时", + "timeFormatSavedToast": "时间格式已保存。", "themeSystem": "跟随系统", "themeSysLabel": "使用系统设置", "themeLight": "浅色", diff --git a/public/pages/calendar.js b/public/pages/calendar.js index 1553d7e..d5cb96a 100644 --- a/public/pages/calendar.js +++ b/public/pages/calendar.js @@ -8,7 +8,7 @@ import { api } from '/api.js'; import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js'; import { openModal as openSharedModal, closeModal } from '/components/modal.js'; import { stagger } from '/utils/ux.js'; -import { t, formatDate as formatPreferredDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid } from '/i18n.js'; +import { t, formatDate as formatPreferredDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid, formatTimeInput, parseTimeInput, timeInputPlaceholder } from '/i18n.js'; import { esc, fmtLocation } from '/utils/html.js'; import { refresh as refreshReminders } from '/reminders.js'; @@ -171,6 +171,7 @@ const EVENT_ICONS = [ const CUSTOM_EVENT_ICONS = new Set(['tooth']); const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024; const ATTACHMENT_IMAGE_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']); +const CALENDAR_VIEW_STORAGE_KEY = 'oikos-calendar-view'; const HOUR_HEIGHT = 56; // px pro Stunde in Wochen-/Tagesansicht @@ -212,6 +213,22 @@ let _container = null; function pad(n) { return String(n).padStart(2, '0'); } function isoDate(d) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } +function getSavedCalendarView() { + try { + const saved = localStorage.getItem(CALENDAR_VIEW_STORAGE_KEY); + return VIEWS.includes(saved) ? saved : 'month'; + } catch { + return 'month'; + } +} + +function setSavedCalendarView(view) { + if (!VIEWS.includes(view)) return; + try { + localStorage.setItem(CALENDAR_VIEW_STORAGE_KEY, view); + } catch {} +} + // Extract YYYY-MM-DD in the browser's local timezone from any datetime string. // For date-only strings (≤10 chars) slicing is safe; for datetime strings with an // explicit UTC offset or 'Z' suffix, new Date() converts to local before extraction. @@ -438,7 +455,7 @@ export async function render(container, { user }) { _container = container; state.today = isoDate(new Date()); state.cursor = state.today; - state.view = 'month'; + state.view = getSavedCalendarView(); container.innerHTML = `
@@ -506,6 +523,7 @@ function renderToolbar() { btn.addEventListener('click', async () => { if (btn.dataset.view === state.view) return; state.view = btn.dataset.view; + setSavedCalendarView(state.view); bar.querySelectorAll('[data-view]').forEach((b) => b.classList.toggle('cal-toolbar__view-btn--active', b.dataset.view === state.view) ); @@ -1184,6 +1202,15 @@ function renderCalendarReminderSection(reminder = null, event = null) {
`; } +function bindTimeInputs(root) { + root.querySelectorAll('.js-time-input').forEach((input) => { + input.addEventListener('blur', () => { + const parsed = parseTimeInput(input.value); + if (parsed) input.value = formatTimeInput(parsed); + }); + }); +} + // -------------------------------------------------------- // Event-Modal (Erstellen / Bearbeiten) // -------------------------------------------------------- @@ -1245,6 +1272,7 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) { if (isEdit && event?.all_day) { timeFields.style.display = 'none'; alldayFields.style.display = ''; } bindDateInputs(panel); + bindTimeInputs(panel); const iconInput = panel.querySelector('#modal-icon'); const iconTrigger = panel.querySelector('#modal-icon-trigger'); @@ -1435,7 +1463,7 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
- +
- +
@@ -1553,9 +1581,15 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null, attach end_datetime = end_datetime || null; } else { const sd = readDateInput(overlay, '#modal-start-date'); - const st = overlay.querySelector('#modal-start-time').value; + const stRaw = overlay.querySelector('#modal-start-time').value; + const st = parseTimeInput(stRaw); const ed = readDateInput(overlay, '#modal-end-date'); - const et = overlay.querySelector('#modal-end-time').value; + const etRaw = overlay.querySelector('#modal-end-time').value; + const et = parseTimeInput(etRaw); + if ((stRaw && !st) || (etRaw && !et)) { + window.oikos?.showToast(t('calendar.invalidDate'), 'error'); + return; + } start_datetime = st ? `${sd}T${st}` : sd; end_datetime = ed ? (et ? `${ed}T${et}` : ed) : null; } diff --git a/public/pages/settings.js b/public/pages/settings.js index 6ca6a29..6f195cd 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -199,7 +199,7 @@ export async function render(container, { user }) { let users = []; let googleStatus = { configured: false, connected: false, lastSync: null }; let appleStatus = { configured: false, lastSync: null }; - let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR', date_format: 'mdy', app_name: DEFAULT_APP_NAME }; + let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR', date_format: 'mdy', time_format: '24h', app_name: DEFAULT_APP_NAME }; let categories = []; let icsSubscriptions = []; let apiTokens = []; @@ -226,6 +226,9 @@ export async function render(container, { user }) { if (prefs.date_format) { try { localStorage.setItem('oikos-date-format', prefs.date_format); } catch (_) {} } + if (prefs.time_format) { + try { localStorage.setItem('oikos-time-format', prefs.time_format); } catch (_) {} + } if (prefs.app_name) { try { localStorage.setItem(APP_NAME_STORAGE_KEY, prefs.app_name); } catch (_) {} } @@ -338,6 +341,11 @@ export async function render(container, { user }) { + + @@ -858,6 +866,20 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok }); } + const timeFormatSelect = container.querySelector('#time-format-select'); + if (timeFormatSelect) { + timeFormatSelect.addEventListener('change', async () => { + try { + await api.put('/preferences', { time_format: timeFormatSelect.value }); + try { localStorage.setItem('oikos-time-format', timeFormatSelect.value); } catch (_) {} + window.dispatchEvent(new CustomEvent('time-format-changed', { detail: { timeFormat: timeFormatSelect.value } })); + window.oikos?.showToast(t('settings.timeFormatSavedToast'), 'success'); + } catch (err) { + window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger'); + } + }); + } + const appNameForm = container.querySelector('#app-name-form'); if (appNameForm) { appNameForm.addEventListener('submit', async (e) => { diff --git a/public/pages/tasks.js b/public/pages/tasks.js index f6e09ff..9b2cc06 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -8,7 +8,7 @@ import { api } from '/api.js'; import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js'; import { openModal as openSharedModal, closeModal, wireBlurValidation, validateAll, btnSuccess, btnError, promptModal } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; -import { t, formatDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid } from '/i18n.js'; +import { t, formatDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid, formatTimeInput, parseTimeInput, timeInputPlaceholder } from '/i18n.js'; import { esc } from '/utils/html.js'; import { refresh as refreshReminders } from '/reminders.js'; @@ -361,8 +361,8 @@ function renderModalContent({ task = null, users = [], reminder = null } = {}) {
- +
@@ -566,6 +566,12 @@ function openTaskModal({ task = null, users = [], reminder = null } = {}, contai if (parsed) input.value = formatDateInput(parsed); }); }); + panel.querySelectorAll('.js-time-input').forEach((input) => { + input.addEventListener('blur', () => { + const parsed = parseTimeInput(input.value); + if (parsed) input.value = formatTimeInput(parsed); + }); + }); // Form-Events panel.querySelector('#task-form') @@ -614,11 +620,20 @@ async function handleFormSubmit(e, container) { priority: form.priority.value, category: form.category.value, due_date: dueDate || null, - due_time: form.due_time?.value || null, assigned_to: form.assigned_to.value ? Number(form.assigned_to.value) : null, is_recurring: rrule.is_recurring ? 1 : 0, recurrence_rule: rrule.recurrence_rule, }; + const dueTimeRaw = form.due_time?.value || ''; + const dueTime = parseTimeInput(dueTimeRaw); + if (dueTimeRaw && !dueTime) { + errorEl.textContent = t('calendar.invalidDate'); + errorEl.hidden = false; + submitBtn.disabled = false; + submitBtn.textContent = originalLabel; + return; + } + body.due_time = dueTime || null; if (form.status) body.status = form.status.value; try { diff --git a/public/router.js b/public/router.js index 62c4adc..0105c59 100644 --- a/public/router.js +++ b/public/router.js @@ -300,6 +300,10 @@ async function syncPreferencesOnce() { if (dateFormat) { localStorage.setItem('oikos-date-format', dateFormat); } + const timeFormat = res?.data?.time_format; + if (timeFormat) { + localStorage.setItem('oikos-time-format', timeFormat); + } if (res?.data?.app_name) { setAppName(res.data.app_name); updateBranding(); @@ -1332,6 +1336,17 @@ window.addEventListener('app-name-changed', () => { updateBranding(currentPath || '/'); }); +function refreshCurrentRoute() { + if (!currentPath) return; + setTimeout(() => { + if (!currentPath) return; + navigate(currentPath, false); + }, 0); +} + +window.addEventListener('date-format-changed', refreshCurrentRoute); +window.addEventListener('time-format-changed', refreshCurrentRoute); + // -------------------------------------------------------- // Virtuelle Tastatur: FAB ausblenden wenn Keyboard offen // Erkennung via visualViewport - Höhe < 75% des Fensters = Keyboard aktiv. diff --git a/server/routes/preferences.js b/server/routes/preferences.js index d02888d..ce81401 100644 --- a/server/routes/preferences.js +++ b/server/routes/preferences.js @@ -22,6 +22,8 @@ const DEFAULT_APP_NAME = 'Oikos'; const VALID_DATE_FORMATS = ['mdy', 'dmy', 'ymd']; const DEFAULT_DATE_FORMAT = 'mdy'; +const VALID_TIME_FORMATS = ['24h', '12h']; +const DEFAULT_TIME_FORMAT = '24h'; const VALID_WIDGET_IDS = ['tasks', 'calendar', 'birthdays', 'budget', 'family', 'weather', 'shopping', 'meals', 'notes']; const DEFAULT_WIDGET_CONFIG = JSON.stringify(VALID_WIDGET_IDS.map((id) => ({ id, visible: true }))); @@ -88,6 +90,7 @@ router.get('/', (req, res) => { const visibleMealTypes = raw.split(',').filter((t) => VALID_MEAL_TYPES.includes(t)); const currency = cfgGet('currency') ?? DEFAULT_CURRENCY; const dateFormat = VALID_DATE_FORMATS.includes(cfgGet('date_format')) ? cfgGet('date_format') : DEFAULT_DATE_FORMAT; + const timeFormat = VALID_TIME_FORMATS.includes(cfgGet('time_format')) ? cfgGet('time_format') : DEFAULT_TIME_FORMAT; const appName = cfgGet('app_name') ?? DEFAULT_APP_NAME; const dashboardWidgets = parseWidgetConfig(cfgGet('dashboard_widgets')); @@ -96,6 +99,7 @@ router.get('/', (req, res) => { visible_meal_types: visibleMealTypes, currency, date_format: dateFormat, + time_format: timeFormat, app_name: appName, dashboard_widgets: dashboardWidgets, }, @@ -115,7 +119,7 @@ router.get('/', (req, res) => { router.put('/', (req, res) => { try { - const { visible_meal_types, currency, date_format, app_name, dashboard_widgets } = req.body; + const { visible_meal_types, currency, date_format, time_format, app_name, dashboard_widgets } = req.body; if (visible_meal_types !== undefined) { if (!Array.isArray(visible_meal_types)) { @@ -142,6 +146,13 @@ router.put('/', (req, res) => { cfgSet('date_format', date_format); } + if (time_format !== undefined) { + if (!VALID_TIME_FORMATS.includes(time_format)) { + return res.status(400).json({ error: `Invalid time format. Allowed: ${VALID_TIME_FORMATS.join(', ')}`, code: 400 }); + } + cfgSet('time_format', time_format); + } + if (app_name !== undefined) { const vAppName = str(app_name, 'Application name', { max: MAX_SHORT, required: false }); if (vAppName.error) return res.status(400).json({ error: vAppName.error, code: 400 }); @@ -161,6 +172,7 @@ router.put('/', (req, res) => { const savedMealTypes = rawMealTypes.split(',').filter((t) => VALID_MEAL_TYPES.includes(t)); const savedCurrency = cfgGet('currency') ?? DEFAULT_CURRENCY; const savedDateFormat = VALID_DATE_FORMATS.includes(cfgGet('date_format')) ? cfgGet('date_format') : DEFAULT_DATE_FORMAT; + const savedTimeFormat = VALID_TIME_FORMATS.includes(cfgGet('time_format')) ? cfgGet('time_format') : DEFAULT_TIME_FORMAT; const savedAppName = cfgGet('app_name') ?? DEFAULT_APP_NAME; const savedWidgets = parseWidgetConfig(cfgGet('dashboard_widgets')); @@ -169,6 +181,7 @@ router.put('/', (req, res) => { visible_meal_types: savedMealTypes, currency: savedCurrency, date_format: savedDateFormat, + time_format: savedTimeFormat, app_name: savedAppName, dashboard_widgets: savedWidgets, },