diff --git a/public/index.html b/public/index.html
index 200f444..2220ac9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -19,6 +19,7 @@
+
@@ -35,6 +36,14 @@
+
+
+
diff --git a/public/pages/calendar.js b/public/pages/calendar.js
index 7ac1127..526fd55 100644
--- a/public/pages/calendar.js
+++ b/public/pages/calendar.js
@@ -5,6 +5,7 @@
*/
import { api } from '/api.js';
+import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
// --------------------------------------------------------
// Konstanten
@@ -606,7 +607,7 @@ function renderAgendaEvent(ev) {
@@ -370,6 +374,9 @@ function openModal(html) {
document.body.insertAdjacentHTML('beforeend', html);
if (window.lucide) window.lucide.createIcons();
+ // RRULE-Events binden
+ bindRRuleEvents(document, 'task');
+
// Fokus auf erstes Eingabefeld
setTimeout(() => document.getElementById('task-title')?.focus(), 50);
@@ -407,14 +414,17 @@ async function handleFormSubmit(e, container) {
submitBtn.disabled = true;
submitBtn.textContent = 'Wird gespeichert…';
+ const rrule = getRRuleValues(document, 'task');
const body = {
- title: form.title.value.trim(),
- description: form.description.value.trim() || null,
- priority: form.priority.value,
- category: form.category.value,
- due_date: form.due_date?.value || null,
- due_time: form.due_time?.value || null,
- assigned_to: form.assigned_to.value ? Number(form.assigned_to.value) : null,
+ title: form.title.value.trim(),
+ description: form.description.value.trim() || null,
+ priority: form.priority.value,
+ category: form.category.value,
+ due_date: form.due_date?.value || 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,
};
if (form.status) body.status = form.status.value;
diff --git a/public/rrule-ui.js b/public/rrule-ui.js
new file mode 100644
index 0000000..2f6a8cf
--- /dev/null
+++ b/public/rrule-ui.js
@@ -0,0 +1,190 @@
+/**
+ * Modul: RRULE UI-Helfer
+ * Zweck: Wiederholungs-Formular (HTML + Logik) fĂĽr Aufgaben- und Kalender-Modals
+ * Abhängigkeiten: keine
+ */
+
+const FREQ_OPTIONS = [
+ { value: '', label: 'Keine Wiederholung' },
+ { value: 'DAILY', label: 'Täglich' },
+ { value: 'WEEKLY', label: 'Wöchentlich' },
+ { value: 'MONTHLY', label: 'Monatlich' },
+];
+
+const WEEKDAYS = [
+ { value: 'MO', label: 'Mo' },
+ { value: 'TU', label: 'Di' },
+ { value: 'WE', label: 'Mi' },
+ { value: 'TH', label: 'Do' },
+ { value: 'FR', label: 'Fr' },
+ { value: 'SA', label: 'Sa' },
+ { value: 'SU', label: 'So' },
+];
+
+/**
+ * Parsed einen RRULE-String in ein Objekt fĂĽr die UI.
+ * @param {string|null} rule - z.B. "FREQ=WEEKLY;BYDAY=MO,TH;INTERVAL=2"
+ * @returns {{ freq: string, interval: number, byday: string[], until: string }}
+ */
+export function parseRRule(rule) {
+ const result = { freq: '', interval: 1, byday: [], until: '' };
+ if (!rule) return result;
+
+ for (const segment of rule.split(';')) {
+ const eq = segment.indexOf('=');
+ if (eq === -1) continue;
+ const key = segment.slice(0, eq).toUpperCase();
+ const val = segment.slice(eq + 1);
+
+ if (key === 'FREQ') result.freq = val;
+ if (key === 'INTERVAL') result.interval = parseInt(val, 10) || 1;
+ if (key === 'BYDAY') result.byday = val.split(',').map(d => d.trim());
+ if (key === 'UNTIL') {
+ // YYYYMMDD → YYYY-MM-DD
+ const c = val.replace(/[TZ]/g, '');
+ result.until = `${c.slice(0, 4)}-${c.slice(4, 6)}-${c.slice(6, 8)}`;
+ }
+ }
+ return result;
+}
+
+/**
+ * Baut einen RRULE-String aus den UI-Werten.
+ * @param {{ freq: string, interval: number, byday: string[], until: string }} opts
+ * @returns {string|null} - RRULE-String oder null (keine Wiederholung)
+ */
+export function buildRRule({ freq, interval, byday, until }) {
+ if (!freq) return null;
+
+ const parts = [`FREQ=${freq}`];
+ if (interval > 1) parts.push(`INTERVAL=${interval}`);
+ if (freq === 'WEEKLY' && byday.length > 0) {
+ parts.push(`BYDAY=${byday.join(',')}`);
+ }
+ if (until) {
+ parts.push(`UNTIL=${until.replace(/-/g, '')}T235959Z`);
+ }
+ return parts.join(';');
+}
+
+/**
+ * Rendert das HTML fĂĽr die Wiederholungs-Felder.
+ * @param {string} prefix - ID-Prefix (z.B. "task" oder "event")
+ * @param {string|null} existingRule - bestehende RRULE oder null
+ * @returns {string} HTML-String
+ */
+export function renderRRuleFields(prefix, existingRule) {
+ const parsed = parseRRule(existingRule);
+
+ const freqOpts = FREQ_OPTIONS.map(o =>
+ `
`
+ ).join('');
+
+ const dayBtns = WEEKDAYS.map(d =>
+ `
`
+ ).join('');
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
${dayBtns}
+
+
+
+
+
+
+
+
+ `;
+}
+
+function unitLabel(freq, interval) {
+ const n = interval > 1;
+ if (freq === 'DAILY') return n ? 'Tage' : 'Tag';
+ if (freq === 'WEEKLY') return n ? 'Wochen' : 'Woche';
+ if (freq === 'MONTHLY') return n ? 'Monate' : 'Monat';
+ return '';
+}
+
+/**
+ * Bindet Events an die RRULE-Felder (Freq-Change, Day-Toggle, etc.)
+ * @param {HTMLElement} root - Container-Element
+ * @param {string} prefix - ID-Prefix
+ */
+export function bindRRuleEvents(root, prefix) {
+ const freqSelect = root.querySelector(`#${prefix}-rrule-freq`);
+ const details = root.querySelector(`#${prefix}-rrule-details`);
+ const weekdays = root.querySelector(`#${prefix}-rrule-weekdays`);
+ const unitEl = root.querySelector(`#${prefix}-rrule-unit`);
+ const intervalEl = root.querySelector(`#${prefix}-rrule-interval`);
+
+ if (!freqSelect) return;
+
+ freqSelect.addEventListener('change', () => {
+ const freq = freqSelect.value;
+ if (details) details.hidden = !freq;
+ if (weekdays) weekdays.hidden = freq !== 'WEEKLY';
+ updateUnit();
+ });
+
+ intervalEl?.addEventListener('input', updateUnit);
+
+ // Day-Toggle
+ root.querySelectorAll(`#${prefix}-rrule-weekdays .rrule-day`).forEach(btn => {
+ btn.addEventListener('click', () => {
+ btn.classList.toggle('rrule-day--active');
+ btn.setAttribute('aria-pressed', btn.classList.contains('rrule-day--active'));
+ });
+ });
+
+ function updateUnit() {
+ if (!unitEl) return;
+ const interval = parseInt(intervalEl?.value, 10) || 1;
+ unitEl.textContent = unitLabel(freqSelect.value, interval);
+ }
+}
+
+/**
+ * Liest die aktuellen RRULE-Werte aus dem Formular.
+ * @param {HTMLElement} root - Container-Element
+ * @param {string} prefix - ID-Prefix
+ * @returns {{ is_recurring: boolean, recurrence_rule: string|null }}
+ */
+export function getRRuleValues(root, prefix) {
+ const freq = root.querySelector(`#${prefix}-rrule-freq`)?.value || '';
+ const interval = parseInt(root.querySelector(`#${prefix}-rrule-interval`)?.value, 10) || 1;
+ const until = root.querySelector(`#${prefix}-rrule-until`)?.value || '';
+
+ const byday = [];
+ root.querySelectorAll(`#${prefix}-rrule-weekdays .rrule-day--active`).forEach(btn => {
+ byday.push(btn.dataset.day);
+ });
+
+ const rule = buildRRule({ freq, interval, byday, until });
+ return {
+ is_recurring: !!rule,
+ recurrence_rule: rule,
+ };
+}
diff --git a/public/styles/layout.css b/public/styles/layout.css
index bee41a4..7a4a64d 100644
--- a/public/styles/layout.css
+++ b/public/styles/layout.css
@@ -1020,6 +1020,84 @@
to { opacity: 0; transform: scale(0.95) translateY(4px); }
}
+/* --------------------------------------------------------
+ * RRULE-Felder (Wiederholungs-Formular, shared Tasks + Kalender)
+ * -------------------------------------------------------- */
+
+.rrule-fields {
+ margin-top: var(--space-4);
+ border-top: 1px solid var(--color-border-subtle);
+ padding-top: var(--space-4);
+}
+
+.rrule-details {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ margin-top: var(--space-3);
+ padding: var(--space-3);
+ background: var(--color-surface-2);
+ border-radius: var(--radius-sm);
+}
+
+.rrule-row {
+ display: flex;
+ align-items: flex-end;
+ gap: var(--space-3);
+}
+
+.rrule-interval-wrap {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.rrule-interval-unit {
+ font-size: var(--text-sm);
+ color: var(--color-text-secondary);
+ white-space: nowrap;
+}
+
+.rrule-day-grid {
+ display: flex;
+ gap: var(--space-1);
+ flex-wrap: wrap;
+ margin-top: var(--space-1);
+}
+
+.rrule-day {
+ width: 40px;
+ height: 40px;
+ border-radius: var(--radius-sm);
+ border: 1.5px solid var(--color-border);
+ background: transparent;
+ color: var(--color-text-secondary);
+ font-size: var(--text-sm);
+ font-weight: var(--font-weight-medium);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all var(--transition-fast);
+ min-height: unset;
+}
+
+.rrule-day:hover {
+ border-color: var(--color-accent);
+ color: var(--color-accent);
+}
+
+.rrule-day--active {
+ background: var(--color-accent);
+ border-color: var(--color-accent);
+ color: #fff;
+}
+
+.rrule-day--active:hover {
+ background: var(--color-accent-hover);
+ border-color: var(--color-accent-hover);
+}
+
/* --------------------------------------------------------
* Print-Styles
* -------------------------------------------------------- */
diff --git a/public/styles/settings.css b/public/styles/settings.css
index 8a860c5..385d3f0 100644
--- a/public/styles/settings.css
+++ b/public/styles/settings.css
@@ -262,6 +262,49 @@
width: 100%;
}
+/* --------------------------------------------------------
+ Theme-Toggle
+ -------------------------------------------------------- */
+
+.theme-toggle {
+ display: flex;
+ gap: var(--space-2);
+}
+
+.theme-toggle__btn {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ padding: var(--space-3) var(--space-4);
+ border-radius: var(--radius-sm);
+ border: 1.5px solid var(--color-border);
+ background: transparent;
+ color: var(--color-text-secondary);
+ font-size: var(--text-sm);
+ font-weight: var(--font-weight-medium);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ min-height: 44px;
+}
+
+.theme-toggle__btn:hover {
+ border-color: var(--color-text-secondary);
+ color: var(--color-text-primary);
+}
+
+.theme-toggle__btn--active {
+ background: var(--color-accent-light);
+ border-color: var(--color-accent);
+ color: var(--color-accent);
+}
+
+.theme-toggle__btn--active:hover {
+ border-color: var(--color-accent);
+ color: var(--color-accent);
+}
+
/* --------------------------------------------------------
Abmelden
-------------------------------------------------------- */
diff --git a/public/styles/tokens.css b/public/styles/tokens.css
index 0b787be..202024d 100644
--- a/public/styles/tokens.css
+++ b/public/styles/tokens.css
@@ -241,9 +241,13 @@
/* ================================================================
* Dark Mode
+ * Zwei Selektoren: (1) System-Preference, (2) manueller Override
+ * via data-theme="dark" auf .
+ * data-theme="light" erzwingt Light Mode (kein Dark-Override).
+ * Ohne data-theme folgt die App der System-Einstellung.
* ================================================================ */
@media (prefers-color-scheme: dark) {
- :root {
+ :root:not([data-theme="light"]) {
/* Neutral-Skala invertiert (warm-dunkel) */
--neutral-50: #1A1A18;
--neutral-100: #222220;
@@ -301,3 +305,52 @@
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.45);
}
}
+
+/* Manueller Dark-Mode-Override: data-theme="dark" auf */
+:root[data-theme="dark"] {
+ --neutral-50: #1A1A18;
+ --neutral-100: #222220;
+ --neutral-150: #2A2A28;
+ --neutral-200: #333331;
+ --neutral-250: #3D3D3A;
+ --neutral-300: #48484A;
+ --neutral-400: #636360;
+ --neutral-500: #8E8D89;
+ --neutral-600: #AEADB0;
+ --neutral-700: #C8C7C3;
+ --neutral-800: #E2E1DC;
+ --neutral-900: #F5F4F1;
+ --neutral-950: #FAFAF8;
+
+ --color-surface: #2A2A28;
+ --color-surface-2: #1A1A18;
+ --color-surface-3: #333331;
+
+ --sidebar-bg: #1A1A18;
+ --sidebar-shadow-light: rgba(255, 255, 255, 0.04);
+ --sidebar-shadow-dark: rgba(0, 0, 0, 0.4);
+
+ --color-accent-light: #1A2D4D;
+ --color-accent-subtle: #162442;
+ --color-success-light: #1A3325;
+ --color-warning-light: #332400;
+ --color-danger-light: #3D1C1A;
+ --color-info-light: #1A2D40;
+
+ --meal-breakfast-light: #332400;
+ --meal-lunch-light: #1A3325;
+ --meal-dinner-light: #1A2D4D;
+ --meal-snack-light: #3D2010;
+
+ --color-priority-low-bg: rgba(142, 141, 137, 0.18);
+ --color-priority-medium-bg: rgba(230, 147, 10, 0.18);
+ --color-priority-high-bg: rgba(212, 81, 30, 0.18);
+ --color-priority-urgent-bg: rgba(229, 83, 75, 0.18);
+
+ --color-overlay: rgba(0, 0, 0, 0.6);
+ --color-overlay-light: rgba(0, 0, 0, 0.35);
+
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.25);
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35);
+ --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.45);
+}
diff --git a/public/sw.js b/public/sw.js
index 884c65c..0990118 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -12,9 +12,9 @@
* API: Immer Netzwerk (kein Caching von Nutzerdaten)
*/
-const SHELL_CACHE = 'oikos-shell-v10';
-const PAGES_CACHE = 'oikos-pages-v10';
-const ASSETS_CACHE = 'oikos-assets-v10';
+const SHELL_CACHE = 'oikos-shell-v11';
+const PAGES_CACHE = 'oikos-pages-v11';
+const ASSETS_CACHE = 'oikos-assets-v11';
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
// App-Shell: sofort benötigt für ersten Render
@@ -23,6 +23,7 @@ const APP_SHELL = [
'/index.html',
'/api.js',
'/router.js',
+ '/rrule-ui.js',
'/sw-register.js',
'/lucide.min.js',
'/styles/tokens.css',
diff --git a/server/routes/tasks.js b/server/routes/tasks.js
index dd6752d..c392edb 100644
--- a/server/routes/tasks.js
+++ b/server/routes/tasks.js
@@ -143,13 +143,15 @@ router.post('/', (req, res) => {
const {
title,
- description = null,
- category = 'Sonstiges',
- priority = 'medium',
- due_date = null,
- due_time = null,
- assigned_to = null,
- parent_task_id = null,
+ description = null,
+ category = 'Sonstiges',
+ priority = 'medium',
+ due_date = null,
+ due_time = null,
+ assigned_to = null,
+ parent_task_id = null,
+ is_recurring = 0,
+ recurrence_rule = null,
} = req.body;
// Tiefe begrenzen: Subtasks dĂĽrfen keine eigenen Subtasks haben (max. 2 Ebenen)
@@ -164,11 +166,12 @@ router.post('/', (req, res) => {
const result = db.get().prepare(`
INSERT INTO tasks
(title, description, category, priority, due_date, due_time,
- assigned_to, created_by, parent_task_id)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ assigned_to, created_by, parent_task_id, is_recurring, recurrence_rule)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
title.trim(), description, category, priority,
- due_date, due_time, assigned_to, req.session.userId, parent_task_id
+ due_date, due_time, assigned_to, req.session.userId, parent_task_id,
+ is_recurring ? 1 : 0, recurrence_rule
);
const task = db.get().prepare(`
@@ -200,23 +203,27 @@ router.put('/:id', (req, res) => {
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const {
- title = task.title,
- description = task.description,
- category = task.category,
- priority = task.priority,
- status = task.status,
- due_date = task.due_date,
- due_time = task.due_time,
- assigned_to = task.assigned_to,
+ title = task.title,
+ description = task.description,
+ category = task.category,
+ priority = task.priority,
+ status = task.status,
+ due_date = task.due_date,
+ due_time = task.due_time,
+ assigned_to = task.assigned_to,
+ is_recurring = task.is_recurring,
+ recurrence_rule = task.recurrence_rule,
} = req.body;
db.get().prepare(`
UPDATE tasks SET
title = ?, description = ?, category = ?, priority = ?,
- status = ?, due_date = ?, due_time = ?, assigned_to = ?
+ status = ?, due_date = ?, due_time = ?, assigned_to = ?,
+ is_recurring = ?, recurrence_rule = ?
WHERE id = ?
`).run(title.trim(), description, category, priority,
- status, due_date, due_time, assigned_to, req.params.id);
+ status, due_date, due_time, assigned_to,
+ is_recurring ? 1 : 0, recurrence_rule, req.params.id);
const updated = db.get().prepare(`
SELECT t.*, u.display_name AS assigned_name, u.avatar_color AS assigned_color