(showDeleteOptions = false)}>
@@ -529,6 +568,16 @@
background: hsl(var(--color-muted));
}
+ .btn-primary-full {
+ background: hsl(var(--color-primary));
+ color: hsl(var(--color-primary-foreground));
+ padding: 0.5rem 1rem;
+ }
+
+ .btn-primary-full:hover {
+ opacity: 0.9;
+ }
+
.btn-destructive {
background: hsl(var(--color-error, 0 84% 60%));
color: white;
diff --git a/apps/mana/apps/web/src/lib/modules/calendar/components/EventForm.svelte b/apps/mana/apps/web/src/lib/modules/calendar/components/EventForm.svelte
index 3e49b6749..4a80f7ffa 100644
--- a/apps/mana/apps/web/src/lib/modules/calendar/components/EventForm.svelte
+++ b/apps/mana/apps/web/src/lib/modules/calendar/components/EventForm.svelte
@@ -8,6 +8,7 @@
import { TagField } from '@mana/shared-ui';
import { useAllTags } from '@mana/shared-stores';
import ConflictWarning from './ConflictWarning.svelte';
+ import CustomRecurrenceBuilder from './CustomRecurrenceBuilder.svelte';
interface Props {
mode: 'create' | 'edit';
@@ -105,13 +106,82 @@
let calendarOptions = $derived(calendarsCtx.value.filter((c) => c.isVisible));
// Recurrence options
+ const CUSTOM_VALUE = '__custom__';
const recurrenceOptions = [
{ value: '', label: 'Keine Wiederholung' },
{ value: 'FREQ=DAILY', label: 'Täglich' },
{ value: 'FREQ=WEEKLY', label: 'Wöchentlich' },
{ value: 'FREQ=MONTHLY', label: 'Monatlich' },
{ value: 'FREQ=YEARLY', label: 'Jährlich' },
+ { value: CUSTOM_VALUE, label: 'Benutzerdefiniert...' },
];
+
+ let showCustomBuilder = $state(false);
+
+ // If the initial rule is a custom one (not a simple preset), show it as custom
+ let isCustomRule = $derived(
+ !!recurrenceRule &&
+ !recurrenceOptions.some((o) => o.value === recurrenceRule && o.value !== CUSTOM_VALUE)
+ );
+
+ // The value shown in the select dropdown
+ let selectValue = $derived(isCustomRule ? CUSTOM_VALUE : recurrenceRule);
+
+ function handleRecurrenceChange(e: Event) {
+ const value = (e.target as HTMLSelectElement).value;
+ if (value === CUSTOM_VALUE) {
+ showCustomBuilder = true;
+ } else {
+ recurrenceRule = value;
+ showCustomBuilder = false;
+ }
+ }
+
+ function handleCustomApply(rule: string) {
+ recurrenceRule = rule;
+ showCustomBuilder = false;
+ }
+
+ function formatCustomPreview(rule: string): string {
+ if (!rule) return '';
+ const parts = Object.fromEntries(
+ rule
+ .replace(/^RRULE:/, '')
+ .split(';')
+ .map((p) => p.split('='))
+ );
+ const freqMap: Record = {
+ DAILY: 'Täglich',
+ WEEKLY: 'Wöchentlich',
+ MONTHLY: 'Monatlich',
+ YEARLY: 'Jährlich',
+ };
+ let text = freqMap[parts.FREQ] ?? 'Wiederkehrend';
+ if (parts.INTERVAL && parseInt(parts.INTERVAL) > 1) {
+ const unitMap: Record = {
+ DAILY: 'Tage',
+ WEEKLY: 'Wochen',
+ MONTHLY: 'Monate',
+ YEARLY: 'Jahre',
+ };
+ text = `Alle ${parts.INTERVAL} ${unitMap[parts.FREQ] ?? ''}`;
+ }
+ if (parts.BYDAY) {
+ const dayMap: Record = {
+ MO: 'Mo',
+ TU: 'Di',
+ WE: 'Mi',
+ TH: 'Do',
+ FR: 'Fr',
+ SA: 'Sa',
+ SU: 'So',
+ };
+ text += ` (${parts.BYDAY.split(',')
+ .map((d: string) => dayMap[d] || d)
+ .join(', ')})`;
+ }
+ return text;
+ }
+ {#if showCustomBuilder}
+ (showCustomBuilder = false)}
+ />
+ {/if}
+
diff --git a/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts b/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts
index a52b952a2..dd6f48202 100644
--- a/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts
+++ b/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts
@@ -10,6 +10,13 @@
import { db } from '$lib/data/database';
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
+import { timeBlockTable } from '$lib/data/time-blocks/collections';
+import {
+ cleanupFutureInstances,
+ deleteAllInstances,
+ regenerateForBlock,
+} from '$lib/data/time-blocks/recurrence';
+import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import type { LocalEvent, CalendarEvent } from '../types';
import { CalendarEvents } from '@mana/shared-utils/analytics';
@@ -136,6 +143,181 @@ export const eventsStore = {
}
},
+ /**
+ * Update a single instance of a recurring event.
+ * Marks the instance as an exception so regeneration won't overwrite it.
+ */
+ async updateSingleInstance(
+ id: string,
+ input: {
+ title?: string;
+ description?: string | null;
+ startTime?: string;
+ endTime?: string;
+ isAllDay?: boolean;
+ location?: string | null;
+ color?: string | null;
+ }
+ ) {
+ error = null;
+ try {
+ const event = await db.table('events').get(id);
+ if (!event) return { success: false, error: 'Event not found' };
+
+ // Mark the TimeBlock as an exception
+ const blockUpdates: Record = { isRecurrenceException: true };
+ if (input.startTime !== undefined) blockUpdates.startDate = input.startTime;
+ if (input.endTime !== undefined) blockUpdates.endDate = input.endTime;
+ if (input.isAllDay !== undefined) blockUpdates.allDay = input.isAllDay;
+ if (input.title !== undefined) blockUpdates.title = input.title;
+ if (input.description !== undefined) blockUpdates.description = input.description;
+ if (input.color !== undefined) blockUpdates.color = input.color;
+
+ await updateBlock(event.timeBlockId, blockUpdates);
+
+ // Update LocalEvent
+ const localData: Partial = { updatedAt: new Date().toISOString() };
+ if (input.title !== undefined) localData.title = input.title;
+ if (input.description !== undefined) localData.description = input.description;
+ if (input.location !== undefined) localData.location = input.location;
+ if (input.color !== undefined) localData.color = input.color;
+
+ await db.table('events').update(id, localData);
+ CalendarEvents.eventUpdated();
+ return { success: true };
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to update instance';
+ return { success: false, error };
+ }
+ },
+
+ /**
+ * Update this and all future instances — updates the template rule/data
+ * and regenerates future instances.
+ */
+ async updateAllFuture(
+ id: string,
+ input: {
+ title?: string;
+ description?: string | null;
+ startTime?: string;
+ endTime?: string;
+ isAllDay?: boolean;
+ location?: string | null;
+ recurrenceRule?: string | null;
+ color?: string | null;
+ }
+ ) {
+ error = null;
+ try {
+ const event = await db.table('events').get(id);
+ if (!event) return { success: false, error: 'Event not found' };
+
+ // Find the template block (parent)
+ const block = await timeBlockTable.get(event.timeBlockId);
+ const templateBlockId = block?.parentBlockId || event.timeBlockId;
+
+ // Update template block
+ const blockUpdates: Record = {};
+ if (input.startTime !== undefined) blockUpdates.startDate = input.startTime;
+ if (input.endTime !== undefined) blockUpdates.endDate = input.endTime;
+ if (input.isAllDay !== undefined) blockUpdates.allDay = input.isAllDay;
+ if (input.recurrenceRule !== undefined) blockUpdates.recurrenceRule = input.recurrenceRule;
+ if (input.title !== undefined) blockUpdates.title = input.title;
+ if (input.description !== undefined) blockUpdates.description = input.description;
+ if (input.color !== undefined) blockUpdates.color = input.color;
+
+ if (Object.keys(blockUpdates).length > 0) {
+ await updateBlock(templateBlockId, blockUpdates);
+ }
+
+ // Update template's LocalEvent
+ const templateEvent = await db
+ .table('events')
+ .where('timeBlockId')
+ .equals(templateBlockId)
+ .first();
+ if (templateEvent) {
+ const localData: Partial = { updatedAt: new Date().toISOString() };
+ if (input.title !== undefined) localData.title = input.title;
+ if (input.description !== undefined) localData.description = input.description;
+ if (input.location !== undefined) localData.location = input.location;
+ if (input.color !== undefined) localData.color = input.color;
+ await db.table('events').update(templateEvent.id, localData);
+ }
+
+ // Regenerate future instances
+ await regenerateForBlock(templateBlockId);
+ CalendarEvents.eventUpdated();
+ return { success: true };
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to update series';
+ return { success: false, error };
+ }
+ },
+
+ /**
+ * Delete a single instance of a recurring event.
+ */
+ async deleteSingleInstance(id: string) {
+ error = null;
+ try {
+ const event = await db.table('events').get(id);
+ if (event?.timeBlockId) {
+ await deleteBlock(event.timeBlockId);
+ }
+ await db.table('events').update(id, {
+ deletedAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ });
+ CalendarEvents.eventDeleted();
+ return { success: true };
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to delete instance';
+ return { success: false, error };
+ }
+ },
+
+ /**
+ * Delete entire recurring series (template + all instances).
+ */
+ async deleteAllInSeries(id: string) {
+ error = null;
+ try {
+ const event = await db.table('events').get(id);
+ if (!event) return { success: false, error: 'Event not found' };
+
+ const block = await timeBlockTable.get(event.timeBlockId);
+ const templateBlockId = block?.parentBlockId || event.timeBlockId;
+
+ // Delete all instances
+ await deleteAllInstances(templateBlockId);
+
+ // Soft-delete all LocalEvents linked to instance blocks
+ const instanceBlocks = await timeBlockTable
+ .where('parentBlockId')
+ .equals(templateBlockId)
+ .toArray();
+ const blockIds = new Set([templateBlockId, ...instanceBlocks.map((b) => b.id)]);
+ const allEvents = await db.table('events').toArray();
+ const now = new Date().toISOString();
+ for (const ev of allEvents) {
+ if (blockIds.has(ev.timeBlockId) && !ev.deletedAt) {
+ await db.table('events').update(ev.id, { deletedAt: now, updatedAt: now });
+ }
+ }
+
+ // Delete template block itself
+ await deleteBlock(templateBlockId);
+
+ CalendarEvents.eventDeleted();
+ return { success: true };
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to delete series';
+ return { success: false, error };
+ }
+ },
+
/**
* Delete an event — soft-deletes both TimeBlock and LocalEvent.
*/
diff --git a/apps/mana/apps/web/src/lib/modules/habits/stores/habits.svelte.ts b/apps/mana/apps/web/src/lib/modules/habits/stores/habits.svelte.ts
index 0784dd0ab..bbda717a9 100644
--- a/apps/mana/apps/web/src/lib/modules/habits/stores/habits.svelte.ts
+++ b/apps/mana/apps/web/src/lib/modules/habits/stores/habits.svelte.ts
@@ -7,7 +7,18 @@
import { habitTable, habitLogTable } from '../collections';
import { toHabit } from '../queries';
-import { createBlock, deleteBlock, startFromScheduled } from '$lib/data/time-blocks/service';
+import {
+ createBlock,
+ deleteBlock,
+ updateBlock,
+ startFromScheduled,
+} from '$lib/data/time-blocks/service';
+import { timeBlockTable } from '$lib/data/time-blocks/collections';
+import {
+ habitScheduleToRRule,
+ materializeRecurringBlocks,
+ regenerateForBlock,
+} from '$lib/data/time-blocks/recurrence';
import { db } from '$lib/data/database';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import type { LocalHabit, LocalHabitLog, HabitSchedule } from '../types';
@@ -140,76 +151,83 @@ export const habitsStore = {
}
},
- /** Set or clear a recurring schedule for a habit. */
+ /**
+ * Set or clear a recurring schedule for a habit.
+ * Creates/updates a template TimeBlock with an RRULE for the unified recurrence engine.
+ */
async setSchedule(habitId: string, schedule: HabitSchedule | null) {
+ const habit = await habitTable.get(habitId);
+ if (!habit) return;
+
+ // Update the habit record
await habitTable.update(habitId, {
schedule,
updatedAt: new Date().toISOString(),
});
- },
- /**
- * Generate scheduled TimeBlocks for habits with schedules for the next N days.
- * Skips days that already have a scheduled block for that habit.
- */
- async generateScheduledBlocks(daysAhead = 7) {
- const habits = await habitTable.toArray();
- const scheduledHabits = habits.filter((h) => !h.deletedAt && !h.isArchived && h.schedule);
-
- if (scheduledHabits.length === 0) return;
-
- const today = new Date();
- today.setHours(0, 0, 0, 0);
-
- // Get existing scheduled habit blocks to avoid duplicates
- const weekEnd = new Date(today);
- weekEnd.setDate(weekEnd.getDate() + daysAhead);
- const existingBlocks = await db
- .table('timeBlocks')
- .where('[type+startDate]')
- .between(['habit', today.toISOString()], ['habit', weekEnd.toISOString()], true, true)
- .toArray();
- const existingKeys = new Set(
- existingBlocks
- .filter((b) => !b.deletedAt && b.kind === 'scheduled')
- .map((b) => `${b.sourceId}-${b.startDate.split('T')[0]}`)
+ // Find existing template block for this habit
+ const existingTemplate = (await timeBlockTable.toArray()).find(
+ (b) =>
+ b.sourceModule === 'habits' &&
+ b.sourceId === habitId &&
+ b.recurrenceRule &&
+ !b.parentBlockId &&
+ !b.deletedAt
);
- for (const habit of scheduledHabits) {
- const schedule = habit.schedule!;
+ if (schedule) {
+ const rrule = habitScheduleToRRule(schedule);
+ const startTime = schedule.time ?? '09:00';
+ const now = new Date();
+ const startISO = `${now.toISOString().split('T')[0]}T${startTime}:00`;
+ const durationMs = habit.defaultDuration ? habit.defaultDuration * 1000 : 3600000;
+ const endISO = new Date(new Date(startISO).getTime() + durationMs).toISOString();
- for (let d = 0; d < daysAhead; d++) {
- const date = new Date(today);
- date.setDate(date.getDate() + d);
- const dayOfWeek = date.getDay(); // 0=Sun
-
- if (!schedule.days.includes(dayOfWeek)) continue;
-
- const dateStr = date.toISOString().split('T')[0];
- const key = `${habit.id}-${dateStr}`;
- if (existingKeys.has(key)) continue;
-
- const startTime = schedule.time ?? '09:00';
- const startISO = `${dateStr}T${startTime}:00`;
- const durationMs = habit.defaultDuration ? habit.defaultDuration * 1000 : 3600000;
- const endISO = new Date(new Date(startISO).getTime() + durationMs).toISOString();
-
- await createBlock({
+ if (existingTemplate) {
+ // Update existing template
+ await updateBlock(existingTemplate.id, {
+ recurrenceRule: rrule,
startDate: startISO,
endDate: endISO,
allDay: !schedule.time,
- kind: 'scheduled',
- type: 'habit',
- sourceModule: 'habits',
- sourceId: habit.id,
title: habit.title,
color: habit.color,
icon: habit.icon,
});
+ await regenerateForBlock(existingTemplate.id);
+ } else {
+ // Create new template block
+ const templateId = await createBlock({
+ startDate: startISO,
+ endDate: endISO,
+ allDay: !schedule.time,
+ recurrenceRule: rrule,
+ kind: 'scheduled',
+ type: 'habit',
+ sourceModule: 'habits',
+ sourceId: habitId,
+ title: habit.title,
+ color: habit.color,
+ icon: habit.icon,
+ });
+ await materializeRecurringBlocks(30);
}
+ } else if (existingTemplate) {
+ // Schedule cleared — delete template and all instances
+ const { deleteAllInstances } = await import('$lib/data/time-blocks/recurrence');
+ await deleteAllInstances(existingTemplate.id);
+ await deleteBlock(existingTemplate.id);
}
},
+ /**
+ * Generate scheduled TimeBlocks for habits using the unified recurrence engine.
+ * Delegates to materializeRecurringBlocks() which handles all recurring templates.
+ */
+ async generateScheduledBlocks(daysAhead = 30) {
+ await materializeRecurringBlocks(daysAhead);
+ },
+
/**
* Log a habit from a scheduled block (plan → reality).
* Creates a logged TimeBlock linked to the scheduled one.