feat(timeblocks): custom recurrence UI, recurring edit/delete prompts, habits migration

- Add CustomRecurrenceBuilder with weekday picker, interval, end conditions
- EventForm: "Benutzerdefiniert..." option opens builder panel
- EventDetailModal: edit/delete prompts for recurring instances (single vs all future)
- Events store: updateSingleInstance, updateAllFuture, deleteSingleInstance, deleteAllInSeries
- Habits: setSchedule() creates template TimeBlock with RRULE via unified engine
- generateScheduledBlocks() now delegates to materializeRecurringBlocks()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-05 21:14:45 +02:00
parent 878424c003
commit 6f4667c2a3
5 changed files with 776 additions and 63 deletions

View file

@ -0,0 +1,367 @@
<script lang="ts">
import { RRule } from 'rrule';
interface Props {
initialRule?: string | null;
onApply: (rule: string) => void;
onCancel: () => void;
}
let { initialRule, onApply, onCancel }: Props = $props();
// Parse initial rule if provided
const parsed = initialRule ? parseRule(initialRule) : null;
let freq = $state<'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'>(parsed?.freq ?? 'WEEKLY');
let interval = $state(parsed?.interval ?? 1);
let selectedDays = $state<number[]>(parsed?.byDay ?? [new Date().getDay()]);
let endType = $state<'never' | 'count' | 'until'>(parsed?.endType ?? 'never');
let count = $state(parsed?.count ?? 10);
let untilDate = $state(parsed?.until ?? '');
const DAYS = [
{ value: 0, short: 'So', rrule: 'SU' },
{ value: 1, short: 'Mo', rrule: 'MO' },
{ value: 2, short: 'Di', rrule: 'TU' },
{ value: 3, short: 'Mi', rrule: 'WE' },
{ value: 4, short: 'Do', rrule: 'TH' },
{ value: 5, short: 'Fr', rrule: 'FR' },
{ value: 6, short: 'Sa', rrule: 'SA' },
];
const FREQ_LABELS: Record<string, string> = {
DAILY: 'Tag(e)',
WEEKLY: 'Woche(n)',
MONTHLY: 'Monat(e)',
YEARLY: 'Jahr(e)',
};
function toggleDay(day: number) {
if (selectedDays.includes(day)) {
if (selectedDays.length > 1) {
selectedDays = selectedDays.filter((d) => d !== day);
}
} else {
selectedDays = [...selectedDays, day].sort();
}
}
function buildRule(): string {
let parts = [`FREQ=${freq}`];
if (interval > 1) parts.push(`INTERVAL=${interval}`);
if (freq === 'WEEKLY' && selectedDays.length > 0 && selectedDays.length < 7) {
const byDay = selectedDays.map((d) => DAYS[d].rrule).join(',');
parts.push(`BYDAY=${byDay}`);
}
if (endType === 'count' && count > 0) {
parts.push(`COUNT=${count}`);
} else if (endType === 'until' && untilDate) {
const d = new Date(untilDate);
const formatted = d
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '')
.split('T')[0];
parts.push(`UNTIL=${formatted}T235959Z`);
}
return parts.join(';');
}
function handleApply() {
onApply(buildRule());
}
function parseRule(rule: string): {
freq: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
interval: number;
byDay: number[];
endType: 'never' | 'count' | 'until';
count: number;
until: string;
} | null {
const clean = rule.replace(/^RRULE:/, '');
const parts = Object.fromEntries(clean.split(';').map((p) => p.split('=')));
const dayMap: Record<string, number> = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };
let endType: 'never' | 'count' | 'until' = 'never';
let count = 10;
let until = '';
if (parts.COUNT) {
endType = 'count';
count = parseInt(parts.COUNT);
} else if (parts.UNTIL) {
endType = 'until';
// Parse UNTIL back to YYYY-MM-DD
const u = parts.UNTIL.replace(/T.*$/, '');
if (u.length === 8) {
until = `${u.slice(0, 4)}-${u.slice(4, 6)}-${u.slice(6, 8)}`;
}
}
return {
freq: (parts.FREQ as 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY') ?? 'WEEKLY',
interval: parts.INTERVAL ? parseInt(parts.INTERVAL) : 1,
byDay: parts.BYDAY ? parts.BYDAY.split(',').map((d: string) => dayMap[d] ?? 0) : [],
endType,
count,
until,
};
}
// Preview text
let preview = $derived.by(() => {
let text = `Alle ${interval > 1 ? interval + ' ' : ''}${FREQ_LABELS[freq]}`;
if (freq === 'WEEKLY' && selectedDays.length > 0 && selectedDays.length < 7) {
text += ` an ${selectedDays.map((d) => DAYS[d].short).join(', ')}`;
}
if (endType === 'count') text += `, ${count}x`;
else if (endType === 'until' && untilDate) text += ` bis ${untilDate}`;
return text;
});
</script>
<div class="recurrence-builder">
<div class="builder-header">Benutzerdefinierte Wiederholung</div>
<!-- Frequency + Interval -->
<div class="builder-row">
<span class="row-label">Alle</span>
<input type="number" class="interval-input" bind:value={interval} min="1" max="99" />
<select class="freq-select" bind:value={freq}>
<option value="DAILY">Tag(e)</option>
<option value="WEEKLY">Woche(n)</option>
<option value="MONTHLY">Monat(e)</option>
<option value="YEARLY">Jahr(e)</option>
</select>
</div>
<!-- Weekday picker (only for WEEKLY) -->
{#if freq === 'WEEKLY'}
<div class="builder-section">
<span class="section-label">Wochentage</span>
<div class="day-picker">
{#each DAYS as day}
<button
type="button"
class="day-btn"
class:active={selectedDays.includes(day.value)}
onclick={() => toggleDay(day.value)}
>
{day.short}
</button>
{/each}
</div>
</div>
{/if}
<!-- End condition -->
<div class="builder-section">
<span class="section-label">Endet</span>
<div class="end-options">
<label class="radio-label">
<input type="radio" bind:group={endType} value="never" />
<span>Nie</span>
</label>
<label class="radio-label">
<input type="radio" bind:group={endType} value="count" />
<span>Nach</span>
{#if endType === 'count'}
<input type="number" class="count-input" bind:value={count} min="1" max="999" />
<span>Terminen</span>
{/if}
</label>
<label class="radio-label">
<input type="radio" bind:group={endType} value="until" />
<span>Am</span>
{#if endType === 'until'}
<input type="date" class="until-input" bind:value={untilDate} />
{/if}
</label>
</div>
</div>
<!-- Preview -->
<div class="preview">{preview}</div>
<!-- Actions -->
<div class="builder-actions">
<button type="button" class="btn btn-secondary" onclick={onCancel}>Abbrechen</button>
<button type="button" class="btn btn-primary" onclick={handleApply}>Übernehmen</button>
</div>
</div>
<style>
.recurrence-builder {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-md, 8px);
background: hsl(var(--color-card));
}
.builder-header {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.builder-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.row-label {
font-size: 0.875rem;
color: hsl(var(--color-foreground));
}
.interval-input {
width: 3.5rem;
padding: 0.375rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-md, 8px);
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
text-align: center;
}
.freq-select {
padding: 0.375rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-md, 8px);
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
}
.builder-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.section-label {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.day-picker {
display: flex;
gap: 0.25rem;
}
.day-btn {
width: 2.25rem;
height: 2.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
}
.day-btn:hover {
background: hsl(var(--color-muted));
}
.day-btn.active {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
.end-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.radio-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: hsl(var(--color-foreground));
cursor: pointer;
}
.radio-label input[type='radio'] {
accent-color: hsl(var(--color-primary));
}
.count-input {
width: 3.5rem;
padding: 0.25rem 0.375rem;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-md, 8px);
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
text-align: center;
}
.until-input {
padding: 0.25rem 0.375rem;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-md, 8px);
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
}
.preview {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
padding: 0.5rem 0.75rem;
background: hsl(var(--color-muted) / 0.5);
border-radius: var(--radius-md, 8px);
font-style: italic;
}
.builder-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.btn {
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md, 8px);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
border: none;
}
.btn-secondary {
background: transparent;
color: hsl(var(--color-foreground));
border: 1px solid hsl(var(--color-border));
}
.btn-secondary:hover {
background: hsl(var(--color-muted));
}
.btn-primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.btn-primary:hover {
opacity: 0.9;
}
</style>

View file

@ -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<typeof eventsStore.updateEvent>[1]) {
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 @@
<button class="btn btn-ghost" onclick={copyToClipboard} title="Kopieren">
{#if copied}<Check size={16} />{:else}<Copy size={16} />{/if}
</button>
<button
class="btn btn-ghost"
onclick={() => (isEditing = true)}
title={$_('common.edit')}
>
<button class="btn btn-ghost" onclick={handleEditClick} title={$_('common.edit')}>
<PencilSimple size={16} />
</button>
<button
@ -289,6 +307,27 @@
</div>
<!-- Recurrence Delete Dialog -->
{#if showEditOptions}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="delete-overlay" onclick={() => (showEditOptions = false)}>
<div class="delete-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<h3 class="delete-title">Wiederkehrenden Termin bearbeiten</h3>
<p class="delete-text">Möchtest du nur diesen Termin oder alle zukünftigen bearbeiten?</p>
<div class="delete-actions">
<button class="btn btn-outline" onclick={() => startEdit('single')}>
Nur diesen Termin
</button>
<button class="btn btn-primary-full" onclick={() => startEdit('all')}>
Alle zukünftigen Termine
</button>
<button class="btn btn-ghost" onclick={() => (showEditOptions = false)}>
{$_('common.cancel')}
</button>
</div>
</div>
</div>
{/if}
{#if showDeleteOptions}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="delete-overlay" onclick={() => (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;

View file

@ -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<string, string> = {
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<string, string> = {
DAILY: 'Tage',
WEEKLY: 'Wochen',
MONTHLY: 'Monate',
YEARLY: 'Jahre',
};
text = `Alle ${parts.INTERVAL} ${unitMap[parts.FREQ] ?? ''}`;
}
if (parts.BYDAY) {
const dayMap: Record<string, string> = {
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;
}
</script>
<form
@ -183,13 +253,26 @@
<div class="field">
<label for="recurrence" class="label">Wiederholung</label>
<select id="recurrence" class="input" bind:value={recurrenceRule}>
<select id="recurrence" class="input" value={selectValue} onchange={handleRecurrenceChange}>
{#each recurrenceOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{#if isCustomRule && !showCustomBuilder}
<button type="button" class="custom-rule-preview" onclick={() => (showCustomBuilder = true)}>
{formatCustomPreview(recurrenceRule)} — Bearbeiten
</button>
{/if}
</div>
{#if showCustomBuilder}
<CustomRecurrenceBuilder
initialRule={recurrenceRule || null}
onApply={handleCustomApply}
onCancel={() => (showCustomBuilder = false)}
/>
{/if}
<div class="field">
<label for="location" class="label">Ort</label>
<input
@ -342,4 +425,18 @@
opacity: 0.5;
cursor: not-allowed;
}
.custom-rule-preview {
font-size: 0.8125rem;
color: hsl(var(--color-primary));
background: none;
border: none;
padding: 0.25rem 0;
cursor: pointer;
text-align: left;
}
.custom-rule-preview:hover {
text-decoration: underline;
}
</style>

View file

@ -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<LocalEvent>('events').get(id);
if (!event) return { success: false, error: 'Event not found' };
// Mark the TimeBlock as an exception
const blockUpdates: Record<string, unknown> = { 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<LocalEvent> = { 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<LocalEvent>('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<string, unknown> = {};
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<LocalEvent>('events')
.where('timeBlockId')
.equals(templateBlockId)
.first();
if (templateEvent) {
const localData: Partial<LocalEvent> = { 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<LocalEvent>('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<LocalEvent>('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<LocalEvent>('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.
*/

View file

@ -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<LocalTimeBlock>('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!;
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;
if (schedule) {
const rrule = habitScheduleToRRule(schedule);
const startTime = schedule.time ?? '09:00';
const startISO = `${dateStr}T${startTime}: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();
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.