diff --git a/apps/mana/apps/web/src/lib/modules/calendar/components/CustomRecurrenceBuilder.svelte b/apps/mana/apps/web/src/lib/modules/calendar/components/CustomRecurrenceBuilder.svelte new file mode 100644 index 000000000..a419c614d --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/calendar/components/CustomRecurrenceBuilder.svelte @@ -0,0 +1,367 @@ + + +
+
Benutzerdefinierte Wiederholung
+ + +
+ Alle + + +
+ + + {#if freq === 'WEEKLY'} +
+ +
+ {#each DAYS as day} + + {/each} +
+
+ {/if} + + +
+ +
+ + + +
+
+ + +
{preview}
+ + +
+ + +
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte b/apps/mana/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte index 759f2122c..10ea22e14 100644 --- a/apps/mana/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte +++ b/apps/mana/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte @@ -33,6 +33,8 @@ let isEditing = $state(false); let showDeleteOptions = $state(false); + let showEditOptions = $state(false); + let editMode = $state<'single' | 'all' | null>(null); let copied = $state(false); let calendarName = $derived( @@ -46,7 +48,7 @@ : getCalendarColor(calendarsCtx.value, event.calendarId) ); let isRecurring = $derived(!!event.recurrenceRule); - let hasParent = $derived(!!event.parentEventId); + let hasParent = $derived(!!event.parentEventId || !!event.parentBlockId); // Format time display function formatEventTime(ev: CalendarEvent): string { @@ -102,17 +104,36 @@ } async function handleSave(data: Parameters[1]) { - await eventsStore.updateEvent(event.id, data); + if (editMode === 'single') { + await eventsStore.updateSingleInstance(event.id, data); + } else if (editMode === 'all') { + await eventsStore.updateAllFuture(event.id, data); + } else { + await eventsStore.updateEvent(event.id, data); + } isEditing = false; + editMode = null; + } + + function handleEditClick() { + if (isRecurring || hasParent) { + showEditOptions = true; + } else { + isEditing = true; + } + } + + function startEdit(mode: 'single' | 'all') { + editMode = mode; + showEditOptions = false; + isEditing = true; } async function handleDelete(mode: 'this' | 'all') { if (mode === 'this') { - await eventsStore.deleteEvent(event.id); + await eventsStore.deleteSingleInstance(event.id); } else { - // Delete all: if this has a parent, delete parent; otherwise delete this - const targetId = event.parentEventId || event.id; - await eventsStore.deleteEvent(targetId); + await eventsStore.deleteAllInSeries(event.id); } showDeleteOptions = false; onClose(); @@ -151,6 +172,7 @@ function handleKeydown(e: KeyboardEvent) { if (e.key === 'Escape') { if (showDeleteOptions) showDeleteOptions = false; + else if (showEditOptions) showEditOptions = false; else onClose(); } } @@ -182,11 +204,7 @@ - + + + + + +{/if} + {#if showDeleteOptions}
(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; + }
- {#each recurrenceOptions as opt} {/each} + {#if isCustomRule && !showCustomBuilder} + + {/if}
+ {#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.