diff --git a/apps/mana/apps/web/src/lib/api/services/qr-export.ts b/apps/mana/apps/web/src/lib/api/services/qr-export.ts index 9d6421840..96e00967b 100644 --- a/apps/mana/apps/web/src/lib/api/services/qr-export.ts +++ b/apps/mana/apps/web/src/lib/api/services/qr-export.ts @@ -1,12 +1,20 @@ /** - * QR Export API Service + * QR Export Service * - * Collects data from contacts, calendar, and todo services for QR export. + * Builds a QR-encoded snapshot of the user's most relevant contacts, + * upcoming calendar events and todo tasks. Reads from the local + * IndexedDB (Dexie) directly — there is no longer a per-app HTTP backend + * to call, all module data lives in the unified `mana` database via + * the local-first sync layer. */ -import { contactsService, type Contact } from './contacts'; -import { calendarService, type CalendarEvent } from './calendar'; -import { todoService, type Task } from './todo'; +import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; +import { toTimeBlock } from '$lib/data/time-blocks/queries'; +import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; +import type { LocalContact } from '$lib/modules/contacts/types'; +import type { LocalEvent } from '$lib/modules/calendar/types'; +import type { LocalTask } from '$lib/modules/todo/types'; import type { UserDataSummary } from './my-data'; import { createManaQRExport, @@ -18,19 +26,15 @@ import { type TodoPriority, } from '@mana/qr-export'; -/** - * Data collected for QR export - */ +/** Data collected for QR export. */ export interface QRExportData { - contacts: Contact[]; - events: CalendarEvent[]; - tasks: Task[]; + contacts: LocalContact[]; + events: Array; + tasks: LocalTask[]; userData: UserDataSummary | null; } -/** - * Result of QR export generation - */ +/** Result of QR export generation. */ export interface QRExportResult { encodeResult: EncodeResult; stats: { @@ -40,54 +44,121 @@ export interface QRExportResult { }; } -/** - * Map Contact to ContactInput for qr-export - */ -function mapContactToInput(contact: Contact): ContactInput { - const displayName = contactsService.getDisplayName(contact); +// ─── Local helpers (replace the deleted *Service modules) ───── - // Determine relation based on available data - // Default to 3 (Freund), but this could be enhanced with actual relation data - let relation: ContactRelation = 3; +/** Best-effort display name for a contact. Mirrors the legacy + * contactsService.getDisplayName so QR output stays consistent. */ +function getContactDisplayName(c: LocalContact): string { + const anyC = c as unknown as Record; + const displayName = anyC.displayName as string | undefined; + if (displayName) return displayName; + if (c.firstName && c.lastName) return `${c.firstName} ${c.lastName}`; + if (c.firstName) return c.firstName; + if (c.lastName) return c.lastName; + if (c.email) return c.email; + return 'Unknown'; +} +/** Top N favorite (or recently updated) contacts. */ +async function loadFavoriteContacts(limit: number): Promise { + const all = await db.table('contacts').toArray(); + const live = all.filter((c) => !c.deletedAt && !c.isArchived); + live.sort((a, b) => { + // Favorites first, then by updatedAt descending. + if (a.isFavorite !== b.isFavorite) return a.isFavorite ? -1 : 1; + return (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''); + }); + return live.slice(0, limit); +} + +/** Upcoming calendar events for the next `days` days. Pulls scheduling + * info from the linked timeBlocks (events table no longer holds dates + * directly after the v3 schema migration). */ +async function loadUpcomingEvents(days: number): Promise { + const horizon = new Date(Date.now() + days * 86_400_000).toISOString(); + const now = new Date().toISOString(); + + const blocks = await db.table('timeBlocks').toArray(); + const candidateBlocks = blocks + .filter( + (b) => + !b.deletedAt && + b.sourceModule === 'calendar' && + b.type === 'event' && + b.startDate >= now && + b.startDate <= horizon + ) + .sort((a, b) => a.startDate.localeCompare(b.startDate)); + const upcomingBlocks = await decryptRecords('timeBlocks', candidateBlocks); + + const allEvents = await db.table('events').toArray(); + const visibleEvents = allEvents.filter((e) => !e.deletedAt); + const decryptedEvents = await decryptRecords('events', visibleEvents); + const eventsById = new Map(); + for (const e of decryptedEvents) { + eventsById.set(e.id, e); + } + + return upcomingBlocks + .map((block) => { + const tb = toTimeBlock(block); + const event = eventsById.get(block.sourceId); + if (!event) return null; + return { + ...event, + startTime: tb.startDate, + endTime: tb.endDate, + isAllDay: tb.allDay, + }; + }) + .filter((e): e is NonNullable => e !== null); +} + +/** Tasks with a dueDate inside the next `days` days, soft-deleted out. */ +async function loadUpcomingTasks(days: number): Promise { + const horizon = new Date(Date.now() + days * 86_400_000).toISOString(); + const all = await db.table('tasks').toArray(); + const visible = all.filter( + (t) => !t.deletedAt && !t.isCompleted && t.dueDate && t.dueDate <= horizon + ); + const decrypted = await decryptRecords('tasks', visible); + return decrypted.sort((a, b) => (a.dueDate ?? '').localeCompare(b.dueDate ?? '')); +} + +// ─── Mappers (unchanged in spirit, retargeted at the local types) ─── + +function mapContactToInput(contact: LocalContact): ContactInput { + const displayName = getContactDisplayName(contact); + const relation: ContactRelation = 3; // default Freund + + const anyC = contact as unknown as Record; return { name: displayName, - phone: contact.mobile || contact.phone, + phone: (anyC.mobile as string | undefined) ?? contact.phone, email: contact.email, relation, importance: contact.isFavorite ? 100 : 0, }; } -/** - * Map CalendarEvent to EventInput for qr-export - */ -function mapEventToInput(event: CalendarEvent): EventInput { - const startDate = new Date(event.startTime); - const endDate = new Date(event.endTime); - +function mapEventToInput(event: QRExportData['events'][number]): EventInput { return { - title: event.title, - start: startDate, - end: endDate, - location: event.location, + title: event.title ?? '', + start: new Date(event.startTime), + end: new Date(event.endTime ?? event.startTime), + location: (event as { location?: string }).location, allDay: event.isAllDay, }; } -/** - * Map Task to TodoInput for qr-export - */ -function mapTaskToInput(task: Task): TodoInput { - // Map priority string to number +function mapTaskToInput(task: LocalTask): TodoInput { const priorityMap: Record = { urgent: 1, high: 1, medium: 2, low: 3, }; - - const priority = priorityMap[task.priority] || 2; + const priority = priorityMap[task.priority ?? 'medium'] ?? 2; return { title: task.title, @@ -97,32 +168,20 @@ function mapTaskToInput(task: Task): TodoInput { }; } -/** - * QR Export service - */ -export const qrExportService = { - /** - * Collect all data needed for QR export - */ - async collectExportData(): Promise { - // Fetch all data in parallel - const [contactsResult, eventsResult, tasksResult] = await Promise.all([ - contactsService.getFavoriteContacts(10), // Get more, we'll filter - calendarService.getUpcomingEvents(30), // Next 30 days - todoService.getUpcomingTasks(30), // Next 30 days - ]); +// ─── Public service ─────────────────────────────────────────── - return { - contacts: contactsResult.data || [], - events: eventsResult.data || [], - tasks: tasksResult.data || [], - userData: null, // Will be set by caller if needed - }; +export const qrExportService = { + /** Collect contacts/events/tasks needed for the QR export. */ + async collectExportData(): Promise { + const [contacts, events, tasks] = await Promise.all([ + loadFavoriteContacts(10), + loadUpcomingEvents(30), + loadUpcomingTasks(30), + ]); + return { contacts, events, tasks, userData: null }; }, - /** - * Generate QR export from collected data - */ + /** Encode an already-collected dataset into a QR result. */ generateExport( data: QRExportData, options?: { @@ -135,31 +194,26 @@ export const qrExportService = { const maxEvents = options?.maxEvents ?? 10; const maxTodos = options?.maxTodos ?? 15; - // Map to input formats const contactInputs = data.contacts.map(mapContactToInput); const eventInputs = data.events.map(mapEventToInput); const taskInputs = data.tasks.map(mapTaskToInput); - // Build export using the builder const builder = createManaQRExport(); - // Set user context if available if (data.userData?.user) { builder.user({ n: data.userData.user.name || data.userData.user.email.split('@')[0], - l: 'de', // Could be derived from user settings - z: 'Europe/Berlin', // Could be derived from user settings + l: 'de', + z: 'Europe/Berlin', }); } else { builder.userName('Mana User'); } - // Add data using smart selectors builder.contactsFrom(contactInputs, maxContacts); builder.eventsFrom(eventInputs, maxEvents); builder.todosFrom(taskInputs, maxTodos); - // Encode const encodeResult = builder.encode(); return { @@ -172,9 +226,7 @@ export const qrExportService = { }; }, - /** - * Generate QR export with all data fetched automatically - */ + /** One-shot helper used by the QR Export modal. */ async generateFullExport( userData?: UserDataSummary | null, options?: { diff --git a/apps/mana/apps/web/src/lib/components/dashboard/widgets/ActivityFeedWidget.svelte b/apps/mana/apps/web/src/lib/components/dashboard/widgets/ActivityFeedWidget.svelte index f8894a2fe..928abce05 100644 --- a/apps/mana/apps/web/src/lib/components/dashboard/widgets/ActivityFeedWidget.svelte +++ b/apps/mana/apps/web/src/lib/components/dashboard/widgets/ActivityFeedWidget.svelte @@ -9,6 +9,7 @@ import { _ } from 'svelte-i18n'; import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; import { db } from '$lib/data/database'; + import { decryptRecords } from '$lib/data/crypto'; import type { LocalTimeBlock, TimeBlockType } from '$lib/data/time-blocks/types'; import { toTimeBlock } from '$lib/data/time-blocks/queries'; import type { TimeBlock } from '$lib/data/time-blocks/types'; @@ -29,8 +30,9 @@ const recentQuery = useLiveQueryWithDefault(async () => { const locals = await db.table('timeBlocks').toArray(); - return locals - .filter((b) => !b.deletedAt) + const visible = locals.filter((b) => !b.deletedAt); + const decrypted = await decryptRecords('timeBlocks', visible); + return decrypted .map(toTimeBlock) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) .slice(0, MAX_ITEMS); diff --git a/apps/mana/apps/web/src/lib/components/dashboard/widgets/DayTimelineWidget.svelte b/apps/mana/apps/web/src/lib/components/dashboard/widgets/DayTimelineWidget.svelte index 36b9d22dc..5030f3979 100644 --- a/apps/mana/apps/web/src/lib/components/dashboard/widgets/DayTimelineWidget.svelte +++ b/apps/mana/apps/web/src/lib/components/dashboard/widgets/DayTimelineWidget.svelte @@ -9,6 +9,7 @@ import { _ } from 'svelte-i18n'; import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; import { db } from '$lib/data/database'; + import { decryptRecords } from '$lib/data/crypto'; import type { LocalTimeBlock, TimeBlockType } from '$lib/data/time-blocks/types'; import { toTimeBlock, getBlockDuration } from '$lib/data/time-blocks/queries'; import type { TimeBlock } from '$lib/data/time-blocks/types'; @@ -26,10 +27,9 @@ .where('startDate') .between(todayStart, todayEnd, true, true) .toArray(); - return locals - .filter((b) => !b.deletedAt) - .map(toTimeBlock) - .sort((a, b) => a.startDate.localeCompare(b.startDate)); + const visible = locals.filter((b) => !b.deletedAt); + const decrypted = await decryptRecords('timeBlocks', visible); + return decrypted.map(toTimeBlock).sort((a, b) => a.startDate.localeCompare(b.startDate)); }, [] as TimeBlock[]); let blocks = $derived(blocksQuery.value ?? []); diff --git a/apps/mana/apps/web/src/lib/data/cross-app-queries.ts b/apps/mana/apps/web/src/lib/data/cross-app-queries.ts index 70d5dea17..4a686eaaf 100644 --- a/apps/mana/apps/web/src/lib/data/cross-app-queries.ts +++ b/apps/mana/apps/web/src/lib/data/cross-app-queries.ts @@ -7,6 +7,7 @@ import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; import { db } from './database'; +import { decryptRecords } from './crypto'; import type { LocalTask } from '$lib/modules/todo/types'; import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; @@ -27,7 +28,8 @@ import type { LocalDeck as LocalCardDeck, LocalCard } from '$lib/modules/cards/t export function useOpenTasks() { return useLiveQueryWithDefault(async () => { const all = await db.table('tasks').orderBy('order').toArray(); - return all.filter((t) => !t.isCompleted && !t.deletedAt); + const visible = all.filter((t) => !t.isCompleted && !t.deletedAt); + return decryptRecords('tasks', visible); }, [] as LocalTask[]); } @@ -44,7 +46,8 @@ export function useTodayTasks() { .where('dueDate') .belowOrEqual(endOfToday.toISOString()) .toArray(); - return candidates.filter((t) => !t.isCompleted && !t.deletedAt); + const visible = candidates.filter((t) => !t.isCompleted && !t.deletedAt); + return decryptRecords('tasks', visible); }, [] as LocalTask[]); } @@ -66,7 +69,8 @@ export function useUpcomingTasks(days = 7) { .where('dueDate') .between(startOfTomorrow.toISOString(), endOfWindow.toISOString(), true, true) .toArray(); - return candidates.filter((t) => !t.isCompleted && !t.deletedAt); + const visible = candidates.filter((t) => !t.isCompleted && !t.deletedAt); + return decryptRecords('tasks', visible); }, [] as LocalTask[]); } @@ -87,7 +91,8 @@ export function useUpcomingEvents(days = 7) { .where('startDate') .between(now.toISOString(), future.toISOString(), true, true) .toArray(); - return candidates.filter((b) => !b.deletedAt); + const visible = candidates.filter((b) => !b.deletedAt); + return decryptRecords('timeBlocks', visible); }, [] as LocalTimeBlock[]); } diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index c1f190dd7..349a62801 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -95,10 +95,16 @@ export const ENCRYPTION_REGISTRY: Record = { }, // ─── Tasks ─────────────────────────────────────────────── - tasks: { enabled: false, fields: ['title', 'description', 'subtasks', 'metadata'] }, + // Phase 7.1: tasks coordinated with timeBlocks below — title and + // description are duplicated to the TimeBlock for calendar display, + // so both sides have to be encrypted in lockstep. + tasks: { enabled: true, fields: ['title', 'description', 'subtasks', 'metadata'] }, // ─── Calendar ──────────────────────────────────────────── - events: { enabled: false, fields: ['title', 'description', 'location'] }, + // Same coordination as tasks: events.title/description/location are + // mirrored onto a TimeBlock; encrypting only the calendar copy + // would still leak via the timeBlocks table. + events: { enabled: true, fields: ['title', 'description', 'location'] }, // ─── Cycles ────────────────────────────────────────────── // Health data — GDPR Art. 9 sensitive personal data category. @@ -177,6 +183,16 @@ export const ENCRYPTION_REGISTRY: Record = { // for now; broader coverage is a Phase 7 concern that needs a // different storage layout. invItems: { enabled: true, fields: ['description'] }, + + // ─── TimeBlocks (cross-module hub) ─────────────────────── + // Phase 7.1: encrypted alongside tasks + calendar.events + habits + // because the consumer modules denormalize their title/description + // into the timeBlock for cheap calendar rendering. Encrypting only + // the source records would still leak the same fields here. + // Indexed columns (startDate, endDate, kind, type, sourceModule, + // sourceId, parentBlockId, recurrenceDate) all stay plaintext — + // the calendar query layer needs them for range scans. + timeBlocks: { enabled: true, fields: ['title', 'description'] }, }; /** diff --git a/apps/mana/apps/web/src/lib/data/time-blocks/queries.ts b/apps/mana/apps/web/src/lib/data/time-blocks/queries.ts index ea2d87f30..4c15f7642 100644 --- a/apps/mana/apps/web/src/lib/data/time-blocks/queries.ts +++ b/apps/mana/apps/web/src/lib/data/time-blocks/queries.ts @@ -11,6 +11,7 @@ import { liveQuery } from 'dexie'; import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; import type { LocalTimeBlock, TimeBlock, @@ -55,7 +56,9 @@ export function toTimeBlock(local: LocalTimeBlock): TimeBlock { export function useAllTimeBlocks() { return useLiveQueryWithDefault(async () => { const locals = await db.table('timeBlocks').toArray(); - return locals.filter((b) => !b.deletedAt).map(toTimeBlock); + const visible = locals.filter((b) => !b.deletedAt); + const decrypted = await decryptRecords('timeBlocks', visible); + return decrypted.map(toTimeBlock); }, [] as TimeBlock[]); } @@ -70,7 +73,9 @@ export function timeBlocksInRange$(start: string, end: string) { .where('startDate') .between(start, end, true, true) .toArray(); - return locals.filter((b) => !b.deletedAt).map(toTimeBlock); + const visible = locals.filter((b) => !b.deletedAt); + const decrypted = await decryptRecords('timeBlocks', visible); + return decrypted.map(toTimeBlock); }); } @@ -82,7 +87,9 @@ export function timeBlocksBySource$(sourceModule: TimeBlockSourceModule, sourceI .where('[sourceModule+sourceId]') .equals([sourceModule, sourceId]) .toArray(); - return locals.filter((b) => !b.deletedAt).map(toTimeBlock); + const visible = locals.filter((b) => !b.deletedAt); + const decrypted = await decryptRecords('timeBlocks', visible); + return decrypted.map(toTimeBlock); }); } @@ -90,10 +97,14 @@ export function timeBlocksBySource$(sourceModule: TimeBlockSourceModule, sourceI export function useLiveTimeBlock() { return useLiveQueryWithDefault( async () => { - // Can't index boolean in Dexie reliably, so scan and filter + // Can't index boolean in Dexie reliably, so scan and filter. + // isLive is a plaintext column so we can find before decrypting, + // then only decrypt the single row we actually need. const locals = await db.table('timeBlocks').toArray(); const active = locals.find((b) => b.isLive && !b.deletedAt); - return active ? toTimeBlock(active) : null; + if (!active) return null; + const [decrypted] = await decryptRecords('timeBlocks', [active]); + return toTimeBlock(decrypted); }, null as TimeBlock | null ); diff --git a/apps/mana/apps/web/src/lib/data/time-blocks/service.ts b/apps/mana/apps/web/src/lib/data/time-blocks/service.ts index 60a305957..4b06fc28e 100644 --- a/apps/mana/apps/web/src/lib/data/time-blocks/service.ts +++ b/apps/mana/apps/web/src/lib/data/time-blocks/service.ts @@ -3,9 +3,21 @@ * * Module stores create both their domain record and a timeBlock in the same * Dexie transaction to keep them consistent. + * + * Phase 7.1 encryption: title + description are encrypted at rest. The + * consumer modules (todo, calendar, habits, times) flow their plaintext + * snapshots through this service, which wraps them via encryptRecord + * before the actual Dexie write — so every caller gets encryption for + * free without touching their own code paths. + * + * `getBlock` returns the raw row (still encrypted). Read-paths must go + * through queries.ts which calls decryptRecord on the way out, OR call + * decryptBlock() explicitly if reading via getBlock for write-coupling + * (e.g. startFromScheduled needs the plaintext title to copy it forward). */ import { db } from '$lib/data/database'; +import { encryptRecord, decryptRecord } from '$lib/data/crypto'; import { timeBlockTable } from './collections'; import type { LocalTimeBlock, CreateTimeBlockInput, UpdateTimeBlockInput } from './types'; @@ -36,6 +48,9 @@ export async function createBlock(input: CreateTimeBlockInput): Promise updatedAt: now, }; + // Encrypt configured fields (title + description) before write. + // All other columns stay plaintext for indexed queries. + await encryptRecord('timeBlocks', block); await timeBlockTable.add(block); return id; } @@ -43,10 +58,12 @@ export async function createBlock(input: CreateTimeBlockInput): Promise /** Update an existing timeBlock. */ export async function updateBlock(id: string, input: UpdateTimeBlockInput): Promise { const now = new Date().toISOString(); - await timeBlockTable.update(id, { + const diff: Partial = { ...input, updatedAt: now, - }); + }; + await encryptRecord('timeBlocks', diff); + await timeBlockTable.update(id, diff); } /** Soft-delete a timeBlock. */ @@ -84,6 +101,13 @@ export async function startFromScheduled( const scheduled = await timeBlockTable.get(scheduledId); if (!scheduled || scheduled.deletedAt) throw new Error('Scheduled block not found'); + // scheduled.title is encrypted on disk — decrypt before forwarding + // to createBlock, otherwise the new logged block would carry the + // already-encrypted blob through encryptRecord again. encryptRecord + // is idempotent on already-encrypted strings, so this is defence-in- + // depth: future code that compares titles needs the plaintext anyway. + const decryptedScheduled = await decryptRecord('timeBlocks', { ...scheduled }); + const now = new Date().toISOString(); const loggedId = await createBlock({ startDate: now, @@ -94,7 +118,7 @@ export async function startFromScheduled( sourceModule: scheduled.sourceModule, sourceId: scheduled.sourceId, linkedBlockId: scheduledId, - title: overrides?.title ?? scheduled.title, + title: overrides?.title ?? decryptedScheduled.title, color: overrides?.color ?? scheduled.color ?? null, icon: overrides?.icon ?? scheduled.icon ?? null, projectId: overrides?.projectId ?? scheduled.projectId ?? null, @@ -106,9 +130,27 @@ export async function startFromScheduled( return loggedId; } -/** Get a single timeBlock by ID. */ +/** + * Get a single timeBlock by ID. Returns the raw row WITH ciphertext + * still in the encrypted columns — caller is responsible for calling + * `decryptBlock` if they need the plaintext title/description. + * + * Read-paths via queries.ts already decrypt automatically; getBlock + * is the explicit escape hatch for code that needs the row outside + * a liveQuery (e.g. write-coupling helpers like startFromScheduled). + */ export async function getBlock(id: string): Promise { const block = await timeBlockTable.get(id); if (block?.deletedAt) return undefined; return block; } + +/** + * Returns a decrypted copy of a single timeBlock — convenience for the + * few callers that need plaintext title/description outside of the + * liveQuery layer. Mutates a fresh copy, never the original row, so the + * IndexedDB record stays encrypted. + */ +export async function decryptBlock(block: LocalTimeBlock): Promise { + return decryptRecord('timeBlocks', { ...block }); +} diff --git a/apps/mana/apps/web/src/lib/modules/calendar/components/CalendarHeader.svelte b/apps/mana/apps/web/src/lib/modules/calendar/components/CalendarHeader.svelte index 8943e8fa7..2c72616e4 100644 --- a/apps/mana/apps/web/src/lib/modules/calendar/components/CalendarHeader.svelte +++ b/apps/mana/apps/web/src/lib/modules/calendar/components/CalendarHeader.svelte @@ -14,6 +14,7 @@ Export, } from '@mana/shared-icons'; import { db } from '$lib/data/database'; + import { decryptRecords } from '$lib/data/crypto'; import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; import { toTimeBlock } from '$lib/data/time-blocks/queries'; import { downloadICalendar } from '$lib/data/time-blocks/ical-export'; @@ -41,8 +42,11 @@ async function handleExport() { const locals = await db.table('timeBlocks').toArray(); - const blocks = locals - .filter((b) => !b.deletedAt) + const visible = locals.filter((b) => !b.deletedAt); + // iCal export embeds the title/description in the file — must + // decrypt before writing or we'd ship ciphertext to the user. + const decrypted = await decryptRecords('timeBlocks', visible); + const blocks = decrypted .map(toTimeBlock) .filter((b) => calendarViewStore.visibleBlockTypes.has(b.type)); downloadICalendar(blocks); diff --git a/apps/mana/apps/web/src/lib/modules/calendar/components/ConflictWarning.svelte b/apps/mana/apps/web/src/lib/modules/calendar/components/ConflictWarning.svelte index 88134f1fd..2f6c5f0cc 100644 --- a/apps/mana/apps/web/src/lib/modules/calendar/components/ConflictWarning.svelte +++ b/apps/mana/apps/web/src/lib/modules/calendar/components/ConflictWarning.svelte @@ -4,6 +4,7 @@ -->