mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
878424c003
commit
6f4667c2a3
5 changed files with 776 additions and 63 deletions
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue