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/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/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/pages/calendar.js b/public/pages/calendar.js index b21448d..667348d 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'; @@ -1204,6 +1204,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) // -------------------------------------------------------- @@ -1265,6 +1274,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'); @@ -1455,7 +1465,7 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
- +
- +
@@ -1573,9 +1583,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..e7e8376 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: `Ungültiges Zeitformat. Erlaubt: ${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, },