From b545d83f6421166cf61c230f1b974d9b74500f0b Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Wed, 6 May 2026 07:00:07 +0200 Subject: [PATCH] fix: modal onClose callback, calendar popup truncation, datetime validation - modal.js: add onClose callback to openModal(), fix _initialFormTimeout cleanup, deduplicate escape/overlay-click handlers in confirmModal, promptModal, selectModal using the new callback - calendar.js: replace popup.innerHTML with insertAdjacentHTML (constraint), add truncateDescription() to cap long event descriptions at 500 chars - validate.js: extend DATETIME_RE to cover ISO 8601 with ms/timezone, normalise datetime values to YYYY-MM-DDTHH:MM on input - index.js: include app version in startup log message - docker-compose.yml / .env.example: switch from named volume to host-mounted DATA_DIR/BACKUP_DIR with sane defaults - docs/installation.md: document host-mount data paths Co-Authored-By: Rafael Foster Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 5 +- docker-compose.yml | 10 +- docs/installation.md | 13 +- public/components/modal.js | 218 ++++++++++------------------------ public/pages/calendar.js | 13 +- server/index.js | 2 +- server/middleware/validate.js | 12 +- 7 files changed, 101 insertions(+), 172 deletions(-) diff --git a/.env.example b/.env.example index 6d35c3b..401cb66 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,10 @@ PORT=3000 NODE_ENV=production # LOG_LEVEL=info # debug, info, warn, error (default: info) +# Docker storage paths +# DATA_DIR=./data # Host folder for the database and stored app data +# BACKUP_DIR=./backups # Host folder for scheduled backups + # Session SESSION_SECRET=REPLACE_WITH_A_LONG_RANDOM_STRING # SESSION_SECURE=false # Only set when not using HTTPS/reverse proxy (e.g. direct localhost) @@ -36,7 +40,6 @@ SYNC_INTERVAL_MINUTES=15 # Automatic Backups # BACKUP_ENABLED=true # Enable/disable automated backups (default: true) # BACKUP_SCHEDULE=0 2 * * * # Cron schedule (default: 2 AM daily) -# BACKUP_DIR=./backups # Backup directory (default: ./backups) # BACKUP_KEEP=7 # Number of backups to keep (default: 7) # TZ=Europe/Berlin # Timezone for scheduled backups (default: UTC) diff --git a/docker-compose.yml b/docker-compose.yml index cd71eb6..36ffaad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,12 +7,14 @@ services: ports: - "0.0.0.0:3000:3000" volumes: - - oikos_data:/data + - ${DATA_DIR:-./data}:/data + - ${BACKUP_DIR:-./backups}:/backups env_file: - .env environment: - NODE_ENV=production - - DB_PATH=/data/oikos.db + - DB_PATH=${DB_PATH:-/data/oikos.db} + - BACKUP_DIR=${BACKUP_DIR:-/backups} # Reverse proxy setup (Caddy, nginx, Traefik): # - Remove SESSION_SECURE=false (default is true) # - TRUST_PROXY is automatically set to 1 (trust one proxy hop) @@ -24,7 +26,3 @@ services: timeout: 10s retries: 3 start_period: 10s - -volumes: - oikos_data: - driver: local diff --git a/docs/installation.md b/docs/installation.md index 3b759d4..f7bcb38 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -59,7 +59,7 @@ Complete setup instructions for Oikos - from Docker installation to your first l ## Architecture Overview -Oikos is a self-hosted family planner that runs as a single Docker container. The Express.js backend serves both the API and the static frontend files. All data is stored in a SQLCipher-encrypted SQLite database inside a Docker volume. +Oikos is a self-hosted family planner that runs as a single Docker container. The Express.js backend serves both the API and the static frontend files. All data is stored in a SQLCipher-encrypted SQLite database inside a host-mounted data folder, and automated backups are written to a separate host-mounted backup folder. ``` Browser ──HTTP──▶ Docker Container (Express.js :3000) ──▶ SQLite/SQLCipher (/data/oikos.db) @@ -453,7 +453,9 @@ docker compose up -d --build ### Where is the Data? -The SQLite database lives in a Docker named volume called `oikos_data`, mounted at `/data` inside the container. The database file is `/data/oikos.db`. +The SQLite database lives in the host folder configured through `DATA_DIR` and is mounted at `/data` inside the container. The database file is `/data/oikos.db`. + +Scheduled backups are written to the host folder configured through `BACKUP_DIR` and mounted at `/backups` inside the container. ### Backup @@ -466,6 +468,13 @@ docker cp oikos:/data/oikos-backup.db ./oikos-backup-$(date +%Y%m%d).db Admins can also download a backup from **Settings → Backup Management**. +If you want to store the database and backups in specific local folders, set these in `.env` before starting Compose: + +```bash +DATA_DIR=./data +BACKUP_DIR=./backups +``` + ### Restore Admins can restore a backup from **Settings → Backup Management**. For operational restores via Docker Compose, stop the running app, mount the backup into a temporary container that uses the same Docker volume, and replace the database file: diff --git a/public/components/modal.js b/public/components/modal.js index 23e9133..d32be11 100644 --- a/public/components/modal.js +++ b/public/components/modal.js @@ -7,7 +7,7 @@ * i18n.js (t) * * API: - * openModal({ title, content, onSave, onDelete, size }) → void + * openModal({ title, content, onSave, onDelete, onClose, size }) → void * closeModal() → void */ @@ -17,6 +17,7 @@ let activeOverlay = null; let previouslyFocused = null; let focusTrapHandler = null; let _initialFormSnapshot = null; +let _initialFormTimeout = null; let _isClosing = false; // Overlay-Dimming: theme-color abdunkeln im Standalone-Modus @@ -110,7 +111,7 @@ function serializeForm(container) { } function isFormDirty(container) { - if (!_initialFormSnapshot) return false; + if (_initialFormSnapshot === null) return false; return serializeForm(container) !== _initialFormSnapshot; } @@ -180,7 +181,6 @@ function _doClose(overlayEl) { target.remove(); // Globalen State nur zurücksetzen wenn kein neues Modal zwischenzeitlich geöffnet wurde. - // (activeOverlay !== target bedeutet: openModal hat bereits ein neues Modal registriert) if (activeOverlay === target) { activeOverlay = null; @@ -211,18 +211,15 @@ function _doClose(overlayEl) { * @param {string} opts.title - Titel im Modal-Header * @param {string} opts.content - HTML-String für den Modal-Body * @param {Function} [opts.onSave] - Callback, wird nach Einfügen in DOM aufgerufen - * (zum Binden von Form-Events) + * @param {Function} [opts.onClose] - Callback, wird aufgerufen wenn das Modal geschlossen wird * @param {Function} [opts.onDelete] - Falls vorhanden, wird ein Löschen-Button eingebaut * @param {string} [opts.size='md'] - 'sm' | 'md' | 'lg' */ -export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}) { +export function openModal({ title, content, onSave, onDelete, onClose, size = 'md' } = {}) { // Vorheriges Modal schließen (kein Stacking). - // ID sofort entfernen damit getElementById() nach dem Einfügen des neuen Modals - // nicht die noch animierende alte Instanz zurückgibt – sonst landen alle - // Event-Listener am falschen Element und Buttons reagieren nicht. - // force=true: kein Dirty-Check beim programmatischen Ersetzen (z.B. confirmModal öffnet sich). if (activeOverlay) { activeOverlay.removeAttribute('id'); + // force:true ensures we don't trigger another dirty check while opening a new modal closeModal({ force: true }); } @@ -252,6 +249,7 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {} document.body.insertAdjacentHTML('beforeend', html); activeOverlay = document.getElementById('shared-modal-overlay'); + activeOverlay._onCloseCallback = onClose; // Lucide-Icons rendern if (window.lucide) window.lucide.createIcons(); @@ -261,8 +259,9 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {} trapFocus(panel); // Snapshot für Dirty-Check (kurzer Delay: Felder könnten noch per JS befüllt werden) + if (_initialFormTimeout) clearTimeout(_initialFormTimeout); _initialFormSnapshot = null; - setTimeout(() => { + _initialFormTimeout = setTimeout(() => { if (activeOverlay) { _initialFormSnapshot = serializeForm(activeOverlay.querySelector('.modal-panel') ?? activeOverlay); } @@ -278,8 +277,7 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {} if (e.target === activeOverlay) closeModal(); }); - // iOS PWA: click-Events auf non-interactive divs sind unzuverlässig → - // touchend als Fallback (passive, damit Scroll nicht blockiert wird) + // iOS PWA: touchend als Fallback activeOverlay.addEventListener('touchend', (e) => { if (e.target === activeOverlay) closeModal(); }, { passive: true }); @@ -288,15 +286,14 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {} activeOverlay.querySelector('[data-action="close-modal"]') ?.addEventListener('click', () => closeModal()); - // Escape + // Escape (nur einmal binden) + document.removeEventListener('keydown', onEscape); document.addEventListener('keydown', onEscape); - // Callback für Aufrufer (Form-Events binden etc.) + // Callback für Aufrufer if (typeof onSave === 'function') onSave(panel); - // Loading-State: btn--loading auf Submit-Button während async-Save. - // rAF-Check: Validierung schlägt fehl → btn bleibt enabled → Loading sofort entfernen. - // MutationObserver: Error-Pfad → btn wird re-enabled → Loading entfernen. + // Loading-State panel.addEventListener('submit', (e) => { const btn = e.target.querySelector('[type="submit"], .btn--primary'); if (!btn || btn.disabled) return; @@ -310,7 +307,7 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {} }); }, { capture: true }); - // Standalone: Statusbar abdunkeln (Overlay-Effekt) + // Standalone: Statusbar abdunkeln if (window.oikos?.setThemeColor) { window.oikos.setThemeColor(OVERLAY_THEME_COLOR, OVERLAY_THEME_COLOR); } @@ -321,53 +318,61 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {} // -------------------------------------------------------- export async function closeModal({ force = false } = {}) { + // If already closing, ignore call if (!activeOverlay || _isClosing) return; - _isClosing = true; if (!force) { const panel = activeOverlay.querySelector('.modal-panel'); if (panel && isFormDirty(panel)) { const dirtyOverlay = activeOverlay; const dirtySnapshot = _initialFormSnapshot; - let confirmed; - try { - activeOverlay = null; - _isClosing = false; - confirmed = await confirmModal(t('modal.unsavedChanges'), { - danger: false, - confirmLabel: t('modal.discardChanges'), - }); - } catch (err) { + const overlayId = dirtyOverlay.id; + + // Momentarily clear global state to allow confirmModal to open without deadlock + dirtyOverlay.removeAttribute('id'); + activeOverlay = null; + + const confirmed = await confirmModal(t('modal.unsavedChanges'), { + danger: false, + confirmLabel: t('modal.discardChanges'), + }); + + if (!confirmed) { + // Restore previous modal state if user cancelled discard + if (overlayId) dirtyOverlay.id = overlayId; activeOverlay = dirtyOverlay; _initialFormSnapshot = dirtySnapshot; - _isClosing = false; - throw err; - } - activeOverlay = dirtyOverlay; - _initialFormSnapshot = dirtySnapshot; - if (!confirmed) { document.body.style.overflow = 'hidden'; if (window.oikos?.setThemeColor) { window.oikos.setThemeColor(OVERLAY_THEME_COLOR, OVERLAY_THEME_COLOR); } - _isClosing = false; return; } - _isClosing = true; + + // User confirmed discard, re-assign activeOverlay so the rest of the logic cleans it up + activeOverlay = dirtyOverlay; } } + // Final closing phase starts here + _isClosing = true; + + if (_initialFormTimeout) { + clearTimeout(_initialFormTimeout); + _initialFormTimeout = null; + } _initialFormSnapshot = null; document.removeEventListener('keydown', onEscape); - // Overlay sofort sichern: Bei Mobile-Animation öffnet openModal() ein neues Modal - // bevor animationend feuert. Ohne capturedOverlay würde _doClose() das neue Modal - // statt des alten entfernen (Race Condition → Buttons im Confirm-Dialog reagieren nicht). const capturedOverlay = activeOverlay; const panel = capturedOverlay.querySelector('.modal-panel'); - // Focus-Trap-Handler und Virtual-Keyboard-Listener entfernen + if (typeof capturedOverlay._onCloseCallback === 'function') { + capturedOverlay._onCloseCallback(); + } + + // Focus-Trap Cleanup if (focusTrapHandler) { if (panel) panel.removeEventListener('keydown', focusTrapHandler); focusTrapHandler = null; @@ -376,12 +381,14 @@ export async function closeModal({ force = false } = {}) { panel.removeEventListener('focusin', panel._onInputFocus); } - // Sheet-Out-Animation auf Mobile, danach _doClose + // Animation handling const isMobile = window.innerWidth < 768; if (isMobile && panel) { panel.classList.add('modal-panel--closing'); - // Fallback-Timer falls animationend nicht feuert (prefers-reduced-motion, Tab-Wechsel etc.) - const fallback = setTimeout(() => { _isClosing = false; _doClose(capturedOverlay); }, 300); + const fallback = setTimeout(() => { + _isClosing = false; + _doClose(capturedOverlay); + }, 400); // Slightly longer fallback panel.addEventListener('animationend', () => { clearTimeout(fallback); _isClosing = false; @@ -395,17 +402,9 @@ export async function closeModal({ force = false } = {}) { } // -------------------------------------------------------- -// promptModal - Ersatz für native prompt() +// promptModal // -------------------------------------------------------- -/** - * Öffnet ein Modal mit Textfeld als Ersatz für native prompt(). - * Gibt ein Promise zurück: string bei OK, null bei Cancel/Escape. - * - * @param {string} label - Beschriftung / Frage - * @param {string} [defaultValue=''] - Vorausgefüllter Wert - * @returns {Promise} - */ export function promptModal(label, defaultValue = '') { return new Promise((resolve) => { let resolved = false; @@ -413,7 +412,7 @@ export function promptModal(label, defaultValue = '') { function finish(value) { if (resolved) return; resolved = true; - closeModal(); + closeModal({ force: true }); resolve(value); } @@ -431,6 +430,7 @@ export function promptModal(label, defaultValue = '') { `, + onClose: () => finish(null), onSave(panel) { const form = panel.querySelector('#prompt-modal-form'); const input = panel.querySelector('#prompt-modal-input'); @@ -443,24 +443,6 @@ export function promptModal(label, defaultValue = '') { cancel.addEventListener('click', () => finish(null)); - // Escape soll null liefern (closeModal wird über onEscape bereits ausgelöst) - const escHandler = (e) => { - if (e.key === 'Escape') { - document.removeEventListener('keydown', escHandler); - finish(null); - } - }; - document.addEventListener('keydown', escHandler); - - // Overlay-Click soll null liefern - const overlay = panel.closest('.modal-overlay'); - if (overlay) { - overlay.addEventListener('click', (e) => { - if (e.target === overlay) finish(null); - }); - } - - // Input fokussieren und Text selektieren setTimeout(() => { input.focus(); input.select(); @@ -471,16 +453,9 @@ export function promptModal(label, defaultValue = '') { } // -------------------------------------------------------- -// selectModal - Ersatz für native prompt() mit Auswahlliste +// selectModal // -------------------------------------------------------- -/** - * Öffnet ein Modal mit Select-Dropdown als Ersatz für native prompt() bei Listenauswahl. - * - * @param {string} label - Beschriftung / Frage - * @param {{ value: string|number, label: string }[]} options - Auswahloptionen - * @returns {Promise} - */ export function selectModal(label, options) { return new Promise((resolve) => { let resolved = false; @@ -488,7 +463,7 @@ export function selectModal(label, options) { function finish(value) { if (resolved) return; resolved = true; - closeModal(); + closeModal({ force: true }); resolve(value); } @@ -509,6 +484,7 @@ export function selectModal(label, options) { `, + onClose: () => finish(null), onSave(panel) { const form = panel.querySelector('#select-modal-form'); const select = panel.querySelector('#select-modal-input'); @@ -520,40 +496,15 @@ export function selectModal(label, options) { }); cancel.addEventListener('click', () => finish(null)); - - const escHandler = (e) => { - if (e.key === 'Escape') { - document.removeEventListener('keydown', escHandler); - finish(null); - } - }; - document.addEventListener('keydown', escHandler); - - const overlay = panel.closest('.modal-overlay'); - if (overlay) { - overlay.addEventListener('click', (e) => { - if (e.target === overlay) finish(null); - }); - } }, }); }); } // -------------------------------------------------------- -// confirmModal - Ersatz für native confirm() +// confirmModal // -------------------------------------------------------- -/** - * Zeigt ein Bestätigungs-Modal als Ersatz für native confirm(). - * Gibt ein Promise zurück: true bei OK, false bei Cancel/Escape/Overlay-Klick. - * - * @param {string} message - Frage / Meldung im Titel - * @param {Object} [opts] - * @param {string} [opts.confirmLabel] - Text des Bestätigungs-Buttons (default: t('common.confirm')) - * @param {boolean} [opts.danger=false] - Roten Danger-Button statt Primary verwenden - * @returns {Promise} - */ export function confirmModal(message, { confirmLabel, danger = false } = {}) { return new Promise((resolve) => { let resolved = false; @@ -561,7 +512,7 @@ export function confirmModal(message, { confirmLabel, danger = false } = {}) { function finish(value) { if (resolved) return; resolved = true; - closeModal(); + closeModal({ force: true }); resolve(value); } @@ -575,31 +526,17 @@ export function confirmModal(message, { confirmLabel, danger = false } = {}) { ${confirmLabel ?? t('common.confirm')} `, + onClose: () => finish(false), onSave(panel) { panel.querySelector('#confirm-modal-ok')?.addEventListener('click', () => finish(true)); panel.querySelector('#confirm-modal-cancel')?.addEventListener('click', () => finish(false)); - - const escHandler = (e) => { - if (e.key === 'Escape') { - document.removeEventListener('keydown', escHandler); - finish(false); - } - }; - document.addEventListener('keydown', escHandler); - - const overlay = panel.closest('.modal-overlay'); - if (overlay) { - overlay.addEventListener('click', (e) => { - if (e.target === overlay) finish(false); - }); - } }, }); }); } // -------------------------------------------------------- -// Inline Blur-Validierung +// Validation & Feedback // -------------------------------------------------------- function _validateField(input) { @@ -625,23 +562,12 @@ function _validateField(input) { return hasValue; } -/** - * Aktiviert Blur-Validierung für alle required-Inputs in einem Container. - * @param {HTMLElement} formContainer - */ export function wireBlurValidation(formContainer) { formContainer.querySelectorAll('input[required], select[required], textarea[required]').forEach((input) => { input.addEventListener('blur', () => _validateField(input)); }); } -/** - * Validiert alle required-Inputs sofort (z.B. beim Submit ohne vorangehendes Blur). - * Markiert Fehler inline und fokussiert das erste ungültige Feld. - * - * @param {HTMLElement} formContainer - * @returns {boolean} true wenn alle Felder valide sind - */ export function validateAll(formContainer) { let firstInvalid = null; let allValid = true; @@ -656,16 +582,6 @@ export function validateAll(formContainer) { return allValid; } -// -------------------------------------------------------- -// Submit-Feedback (Checkmark + Shake) -// -------------------------------------------------------- - -/** - * Zeigt Erfolgs-Feedback auf einem Button (Checkmark für 700ms). - * Respektiert prefers-reduced-motion: zeigt nur Farb-Feedback ohne Icon-Wechsel. - * @param {HTMLButtonElement} btn - * @param {string} [originalLabel] - */ export function btnSuccess(btn, originalLabel) { btn.classList.remove('btn--loading'); const label = originalLabel ?? btn.textContent; @@ -691,13 +607,6 @@ export function btnSuccess(btn, originalLabel) { }, 700); } -/** - * Versetzt einen Button in den Lade-Zustand (Spinner, nicht klickbar). - * Gibt eine Cleanup-Funktion zurück, die den Originalzustand wiederherstellt. - * - * @param {HTMLButtonElement} btn - * @returns {() => void} cleanup - */ export function btnLoading(btn) { btn.classList.add('btn--loading'); btn.disabled = true; @@ -707,11 +616,6 @@ export function btnLoading(btn) { }; } -/** - * Zeigt Fehler-Feedback auf einem Button (Shake-Animation). - * Respektiert prefers-reduced-motion: kein visuelles Schütteln, nur Farb-Feedback. - * @param {HTMLButtonElement} btn - */ export function btnError(btn) { if (matchMedia('(prefers-reduced-motion: reduce)').matches) { btn.classList.add('btn--error-static'); @@ -719,7 +623,7 @@ export function btnError(btn) { return; } btn.classList.remove('btn--shaking'); - void btn.offsetWidth; // Reflow für Animation-Restart + void btn.offsetWidth; btn.classList.add('btn--shaking'); btn.addEventListener('animationend', () => btn.classList.remove('btn--shaking'), { once: true }); } diff --git a/public/pages/calendar.js b/public/pages/calendar.js index 190ce48..0aeccdf 100644 --- a/public/pages/calendar.js +++ b/public/pages/calendar.js @@ -402,6 +402,13 @@ function attachmentHtml(event) { `; } +function truncateDescription(description, maxLength = 500) { + const text = String(description || '').trim(); + if (!text) return ''; + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength)} (...)`; +} + function attachmentPreviewHtml(event) { if (!event?.attachment_data) return ''; const name = esc(event.attachment_name || t('calendar.attachmentFallback')); @@ -1067,14 +1074,14 @@ function showEventPopup(ev, anchor) { + (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)}${t('calendar.timeSuffix') ? ' ' + t('calendar.timeSuffix') : ''}`.trim() : ''); const displayColor = ev.cal_color || ev.color; - popup.innerHTML = ` + popup.insertAdjacentHTML('beforeend', `
${eventIconHtml(ev.icon)}${esc(ev.title)}
${ev.cal_name ? `
${esc(ev.cal_name)}
` : ''}
${timeStr}
${ev.location ? `
📍 ${esc(fmtLocation(ev.location))}
` : ''} - ${ev.description ? `
${esc(ev.description)}
` : ''} + ${ev.description ? `
${esc(truncateDescription(ev.description, 500))}
` : ''} ${ev.attachment_data ? attachmentHtml(ev) : ''} ${ev.assigned_name ? `
👤 ${esc(ev.assigned_name)}
` : ''}
@@ -1084,7 +1091,7 @@ function showEventPopup(ev, anchor) { - `; + `); document.body.appendChild(popup); if (window.lucide) lucide.createIcons(); diff --git a/server/index.js b/server/index.js index 4c76ba8..320bb2e 100644 --- a/server/index.js +++ b/server/index.js @@ -268,7 +268,7 @@ async function runSync() { // Server starten // -------------------------------------------------------- app.listen(PORT, () => { - logOikos.info(`Server running on port ${PORT}`); + logOikos.info(`Server running on port ${PORT} | Version ${APP_VERSION}`); logOikos.info(`Environment: ${process.env.NODE_ENV || 'development'}`); // Erster Sync nach 10 Sekunden (warten bis DB vollständig initialisiert) diff --git a/server/middleware/validate.js b/server/middleware/validate.js index 529a44c..428528b 100644 --- a/server/middleware/validate.js +++ b/server/middleware/validate.js @@ -13,7 +13,7 @@ const MAX_RRULE = 300; // Regex-Muster const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; const TIME_RE = /^\d{2}:\d{2}$/; -const DATETIME_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?Z?)?$/; +const DATETIME_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/; const COLOR_RE = /^#[0-9A-Fa-f]{6}$/; const MONTH_RE = /^\d{4}-\d{2}$/; const RRULE_RE = /^(FREQ=(DAILY|WEEKLY|MONTHLY)(;INTERVAL=\d{1,2})?(;BYDAY=[A-Z,]{2,}(,[A-Z]{2})*)?(;UNTIL=\d{8}(T\d{6}Z)?)?)?$/; @@ -120,7 +120,15 @@ function datetime(val, field, required = false) { } if (!DATETIME_RE.test(String(val))) return { value: null, error: `${field} must be in YYYY-MM-DD or YYYY-MM-DDTHH:MM format.` }; - return { value: String(val), error: null }; + const raw = String(val).trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return { value: raw, error: null }; + const match = raw.match( + /^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/ + ); + if (!match) { + return { value: null, error: `${field} must be in YYYY-MM-DD or YYYY-MM-DDTHH:MM format.` }; + } + return { value: `${match[1]}T${match[2]}:${match[3]}`, error: null }; } /**