feat(timeblocks): unified recurrence engine with rrule.js

Core recurrence engine:
- Add rrule.js dependency for RFC 5545 RRULE expansion
- recurrence.ts: expandRule(), materializeRecurringBlocks(30 days),
  regenerateForBlock(), cleanupFutureInstances(), deleteAllInstances()
- Virtual expansion: expandTemplatesVirtually() for calendar views >30 days
- HabitSchedule ↔ RRULE bidirectional conversion

Schema:
- Dexie v4: add parentBlockId, recurrenceDate, isRecurrenceException
  to timeBlocks with [parentBlockId+recurrenceDate] compound index
- LocalTimeBlock + TimeBlock types updated

Module changes:
- Todo: remove recurrenceRule from LocalTask/Task (lives on TimeBlock)
- Calendar: add parentBlockId to CalendarEvent, repeat icon on EventCard
- Startup: materializeRecurringBlocks(30) runs on calendar layout mount

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-05 19:49:57 +02:00
parent 98ec6c3cbb
commit a787a27daa
11 changed files with 3263 additions and 683 deletions

View file

@ -0,0 +1,84 @@
{
"name": "@mana/web",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test": "vitest",
"test:ui": "vitest --ui",
"test:e2e": "playwright test"
},
"devDependencies": {
"@mana/shared-pwa": "workspace:*",
"@mana/shared-vite-config": "workspace:*",
"@playwright/test": "^1.51.0",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^5.0.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.7",
"@types/node": "^22.10.5",
"@vite-pwa/sveltekit": "^1.1.0",
"@vitest/coverage-v8": "^4.0.14",
"@vitest/ui": "^4.0.14",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.10",
"svelte": "^5.41.0",
"svelte-check": "^4.3.3",
"tailwindcss": "^4.1.17",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"vite": "^6.0.7",
"vitest": "^4.0.14"
},
"dependencies": {
"@calc/shared": "workspace:*",
"@mana/credits": "workspace:^",
"@mana/feedback": "workspace:*",
"@mana/help": "workspace:*",
"@mana/local-llm": "workspace:*",
"@mana/local-store": "workspace:*",
"@mana/qr-export": "workspace:*",
"@mana/shared-auth": "workspace:*",
"@mana/shared-auth-ui": "workspace:*",
"@mana/shared-branding": "workspace:*",
"@mana/shared-config": "workspace:*",
"@mana/shared-error-tracking": "workspace:*",
"@mana/shared-i18n": "workspace:*",
"@mana/shared-icons": "workspace:*",
"@mana/shared-links": "workspace:*",
"@mana/shared-stores": "workspace:*",
"@mana/shared-tags": "workspace:*",
"@mana/shared-tailwind": "workspace:*",
"@mana/shared-theme": "workspace:*",
"@mana/shared-theme-ui": "workspace:*",
"@mana/shared-types": "workspace:*",
"@mana/shared-ui": "workspace:*",
"@mana/shared-uload": "workspace:*",
"@mana/shared-utils": "workspace:*",
"@mana/spiral-db": "workspace:*",
"@mana/subscriptions": "workspace:*",
"@mana/wallpaper-generator": "workspace:*",
"@types/suncalc": "^1.9.2",
"@zitare/content": "workspace:*",
"date-fns": "^4.1.0",
"dexie": "^4.0.11",
"marked": "^17.0.5",
"rrule": "^2.8.1",
"suncalc": "^1.9.0",
"svelte-dnd-action": "^0.9.68",
"svelte-i18n": "^4.0.0",
"svelte-sonner": "^1.0.5"
},
"type": "module"
}

View file

@ -0,0 +1,609 @@
/**
* Unified Dexie Database Single IndexedDB for all Mana apps.
*
* All collections from all app modules are registered in one database.
* Table names that collide across apps are prefixed (e.g., pictureTags, storageTags).
*
* The SYNC_APP_MAP maps each table back to its appId for sync routing.
*/
import Dexie, { type EntityTable } from 'dexie';
import { trackFirstContent } from '$lib/stores/funnel-tracking';
import { fire as fireTrigger } from '$lib/triggers/registry';
import { checkInlineSuggestion } from '$lib/triggers/inline-suggest';
// ─── Database ──────────────────────────────────────────────
export const db = new Dexie('mana');
db.version(1).stores({
// ─── Sync Infrastructure ───
_pendingChanges: '++id, appId, collection, recordId, createdAt',
_syncMeta: '[appId+collection]',
// ─── Core / Mana (appId: 'mana') ───
userSettings: 'id, key',
dashboardConfigs: 'id',
automations: 'id, sourceApp, targetApp, enabled, [sourceApp+sourceCollection]',
// ─── Todo (appId: 'todo') ───
tasks:
'id, dueDate, isCompleted, priority, order, projectId, [isCompleted+order], [projectId+order]',
todoProjects: 'id, order, isArchived, isDefault',
taskLabels: 'id, taskId, labelId', // junction to globalTags (labelId = tagId)
reminders: 'id, taskId',
boardViews: 'id, order, groupBy',
// ─── Calendar (appId: 'calendar') ───
calendars: 'id, isDefault, isVisible',
events: 'id, calendarId, startDate, endDate, allDay, [calendarId+startDate]',
eventTags: 'id, eventId, tagId, [eventId+tagId]',
// ─── Contacts (appId: 'contacts') ───
contacts: 'id, firstName, lastName, email, company, isFavorite, isArchived',
contactTags: 'id, contactId, tagId, [contactId+tagId]',
// ─── Chat (appId: 'chat') ───
conversations: 'id, isArchived, isPinned, spaceId, templateId',
messages: 'id, conversationId, sender, [conversationId+sender]',
chatTemplates: 'id, isDefault',
conversationTags: 'id, conversationId, tagId, [conversationId+tagId]',
// ─── Picture (appId: 'picture') ───
images: 'id, isFavorite, isPublic, isArchived, prompt',
boards: 'id, isPublic',
boardItems: 'id, boardId, itemType, zIndex, [boardId+zIndex]',
imageTags: 'id, imageId, tagId, [imageId+tagId]', // junction to globalTags
// ─── Cards (appId: 'cards') ───
cardDecks: 'id, isPublic',
cards: 'id, deckId, difficulty, nextReview, order, [deckId+order]',
deckTags: 'id, deckId, tagId, [deckId+tagId]',
// ─── Zitare (appId: 'zitare') ───
zitareFavorites: 'id, quoteId',
zitareLists: 'id',
zitareListTags: 'id, listId, tagId, [listId+tagId]',
// ─── Music (appId: 'music') ───
songs: 'id, artist, album, genre, favorite, title',
mukkePlaylists: 'id, name',
playlistSongs: 'id, playlistId, songId, sortOrder, [playlistId+sortOrder]',
mukkeProjects: 'id, title, songId',
markers: 'id, beatId, type, sortOrder',
songTags: 'id, songId, tagId, [songId+tagId]',
// ─── Storage (appId: 'storage') ───
files: 'id, parentFolderId, mimeType, isFavorite, isDeleted, name',
storageFolders: 'id, parentFolderId, path, depth, isFavorite, isDeleted',
fileTags: 'id, fileId, tagId, [fileId+tagId]', // junction to globalTags
// ─── Presi (appId: 'presi') ───
presiDecks: 'id, isPublic',
slides: 'id, deckId, order, [deckId+order]',
presiDeckTags: 'id, deckId, tagId, [deckId+tagId]',
// ─── Inventar (appId: 'inventar') ───
invCollections: 'id, order, templateId',
invItems: 'id, collectionId, locationId, categoryId, status, name, [collectionId+order]',
invLocations: 'id, parentId, path, depth, order',
invCategories: 'id, parentId, order',
invItemTags: 'id, itemId, tagId, [itemId+tagId]',
// ─── Photos (appId: 'photos') ───
albums: 'id, isAutoGenerated, name',
albumItems: 'id, albumId, mediaId, sortOrder, [albumId+sortOrder]',
photoFavorites: 'id, mediaId',
photoMediaTags: 'id, mediaId, tagId, [mediaId+tagId]', // junction to globalTags
// ─── SkillTree (appId: 'skilltree') ───
skills: 'id, branch, parentId, level',
activities: 'id, skillId, timestamp',
achievements: 'id, key, unlockedAt',
skillTags: 'id, skillId, tagId, [skillId+tagId]',
// ─── CityCorners (appId: 'citycorners') ───
cities: 'id, slug, country, name',
ccLocations: 'id, cityId, category, name',
ccFavorites: 'id, locationId',
ccLocationTags: 'id, locationId, tagId, [locationId+tagId]',
// ─── Times (appId: 'times') ───
timeClients: 'id, order, isArchived, shortCode',
timeProjects: 'id, clientId, isArchived, isBillable, guildId, visibility, order',
timeEntries:
'id, projectId, clientId, date, isRunning, [date+projectId], [date+clientId], guildId, visibility',
timeTemplates: 'id, usageCount, lastUsedAt, projectId',
timeSettings: 'id',
timeAlarms: 'id, enabled, time',
timeCountdownTimers: 'id, status',
timeWorldClocks: 'id, sortOrder, timezone',
entryTags: 'id, entryId, tagId, [entryId+tagId]',
// ─── Context (appId: 'context') ───
contextSpaces: 'id, pinned, prefix',
documents: 'id, spaceId, type, pinned, title, [spaceId+type]',
documentTags: 'id, documentId, tagId, [documentId+tagId]',
// ─── Questions (appId: 'questions') ───
qCollections: 'id, sortOrder, isDefault',
questions: 'id, collectionId, status, priority, [collectionId+status]',
answers: 'id, questionId, isAccepted',
questionTags: 'id, questionId, tagId, [questionId+tagId]',
// ─── NutriPhi (appId: 'nutriphi') ───
meals: 'id, date, mealType, [date+mealType]',
goals: 'id',
nutriFavorites: 'id, mealType, usageCount',
mealTags: 'id, mealId, tagId, [mealId+tagId]',
// ─── Planta (appId: 'planta') ───
plants: 'id, isActive, healthStatus',
plantPhotos: 'id, plantId, isPrimary, [plantId+isPrimary]',
wateringSchedules: 'id, plantId, nextWateringAt',
wateringLogs: 'id, plantId, wateredAt',
plantTags: 'id, plantId, tagId, [plantId+tagId]',
// ─── uLoad (appId: 'uload') ───
links: 'id, shortCode, isActive, folderId, order, clickCount, [folderId+order], [isActive+order]',
uloadTags: 'id, slug, name',
uloadFolders: 'id, order',
linkTags: 'id, linkId, tagId, [linkId+tagId]',
// ─── Calc (appId: 'calc') ───
calculations: 'id, mode',
savedFormulas: 'id, mode, name',
// ─── Moodlit (appId: 'moodlit') ───
moods: 'id, name, animation, isDefault',
sequences: 'id, name',
moodTags: 'id, moodId, tagId, [moodId+tagId]',
// ─── Memoro (appId: 'memoro') ───
memos: 'id, processingStatus, isArchived, isPinned, language, [isArchived+createdAt]',
memories: 'id, memoId',
memoTags: 'id, memoId, tagId', // junction to globalTags
memoroSpaces: 'id, ownerId',
spaceMembers: 'id, spaceId, userId',
memoSpaces: 'id, memoId, spaceId',
// ─── Guides (appId: 'guides') ───
guides: 'id, category, difficulty, collectionId, tags',
sections: 'id, guideId, order',
steps: 'id, guideId, sectionId, order, [guideId+order]',
guideCollections: 'id',
runs: 'id, guideId, startedAt, completedAt',
guideTags: 'id, guideId, tagId, [guideId+tagId]',
// ─── Playground (appId: 'playground') ───
// No persistent data — stateless LLM playground
// ─── Habits (appId: 'habits') ───
habits: 'id, order, isArchived, color',
habitLogs: 'id, habitId, timestamp, [habitId+timestamp]',
// ─── Notes (appId: 'notes') ───
notes: 'id, isPinned, isArchived, color, title, updatedAt',
noteTags: 'id, noteId, tagId, [noteId+tagId]',
// ─── Finance (appId: 'finance') ───
transactions: 'id, type, categoryId, date, amount, [date+type], [categoryId+date]',
financeCategories: 'id, type, order',
budgets: 'id, categoryId, month, [month+categoryId]',
// ─── Places (appId: 'places') ───
places: 'id, name, category, isFavorite, isArchived, latitude, longitude',
locationLogs: 'id, placeId, timestamp, [placeId+timestamp]',
placeTags: 'id, placeId, tagId, [placeId+tagId]',
// ─── Shared: Global Tags (appId: 'tags') ───
globalTags: 'id, name, groupId',
tagGroups: 'id',
// ─── Shared: Links (appId: 'links') ───
manaLinks: 'id, sourceAppId, sourceRecordId, targetAppId, targetRecordId',
});
// ─── Schema Migrations ────────────────────────────────────────
// Version 2: Habits emoji → icon field migration
const EMOJI_TO_ICON: Record<string, string> = {
'\u2615': 'coffee',
'\ud83d\udeb6': 'person-simple-walk',
'\ud83c\udfc3': 'person-simple-run',
'\ud83e\uddd8': 'person-simple-tai-chi',
'\ud83d\udca7': 'drop',
'\ud83c\udf4e': 'apple-logo',
'\ud83d\udcda': 'book-open',
'\ud83d\udcaa': 'barbell',
'\ud83d\udecc': 'bed',
'\ud83c\udfb5': 'music-note',
'\ud83d\udc8a': 'pill',
'\ud83c\udf7a': 'beer-stein',
'\ud83c\udf55': 'pizza',
'\ud83d\udeb4': 'bicycle',
'\ud83d\udcdd': 'pencil-simple',
'\ud83e\uddfc': 'tooth',
'\u2b50': 'star',
'\ud83d\ude2e\u200d\ud83d\udca8': 'wind',
};
db.version(2)
.stores({})
.upgrade((tx) => {
return tx
.table('habits')
.toCollection()
.modify((habit: Record<string, unknown>) => {
if (habit.emoji !== undefined && habit.icon === undefined) {
habit.icon = EMOJI_TO_ICON[habit.emoji as string] ?? 'star';
delete habit.emoji;
}
});
});
// ─── Version 3: Unified Time Model (timeBlocks) ─────────────
// Adds timeBlocks table, updates indexes on events/timeEntries/tasks/habitLogs,
// and migrates existing time data into timeBlocks.
db.version(3)
.stores({
// New tables
timeBlocks:
'id, startDate, kind, type, sourceModule, sourceId, [sourceModule+sourceId], [type+startDate], [kind+startDate]',
timeBlockTags: 'id, blockId, tagId, [blockId+tagId]',
// Updated indexes (timeBlockId / scheduledBlockId added)
events: 'id, calendarId, timeBlockId',
timeEntries: 'id, projectId, clientId, timeBlockId, guildId, visibility',
tasks:
'id, dueDate, isCompleted, priority, order, projectId, scheduledBlockId, [isCompleted+order], [projectId+order]',
habitLogs: 'id, habitId, timeBlockId, [habitId+timeBlockId]',
})
.upgrade(async (tx) => {
const timeBlocksTable = tx.table('timeBlocks');
// 1. Migrate calendar events → timeBlocks
const events = await tx.table('events').toArray();
for (const event of events) {
if (!event.startDate) continue;
const blockId = crypto.randomUUID();
await timeBlocksTable.add({
id: blockId,
startDate: event.startDate,
endDate: event.endDate ?? null,
allDay: event.allDay ?? false,
isLive: false,
timezone: null,
recurrenceRule: event.recurrenceRule ?? null,
kind: 'scheduled',
type: 'event',
sourceModule: 'calendar',
sourceId: event.id,
linkedBlockId: null,
title: event.title ?? '',
description: event.description ?? null,
color: event.color ?? null,
icon: null,
projectId: null,
createdAt: event.createdAt ?? new Date().toISOString(),
updatedAt: event.updatedAt ?? new Date().toISOString(),
deletedAt: event.deletedAt ?? null,
});
await tx.table('events').update(event.id, { timeBlockId: blockId });
}
// 2. Migrate time entries → timeBlocks
const entries = await tx.table('timeEntries').toArray();
for (const entry of entries) {
if (!entry.date && !entry.startTime) continue; // skip entries with no date at all
const blockId = crypto.randomUUID();
const startDate = entry.startTime ?? `${entry.date}T00:00:00.000Z`;
await timeBlocksTable.add({
id: blockId,
startDate,
endDate: entry.endTime ?? null,
allDay: false,
isLive: entry.isRunning ?? false,
timezone: null,
recurrenceRule: null,
kind: 'logged',
type: 'timeEntry',
sourceModule: 'times',
sourceId: entry.id,
linkedBlockId: null,
title: entry.description || 'Time Entry',
description: null,
color: null,
icon: null,
projectId: entry.projectId ?? null,
createdAt: entry.createdAt ?? new Date().toISOString(),
updatedAt: entry.updatedAt ?? new Date().toISOString(),
deletedAt: entry.deletedAt ?? null,
});
await tx.table('timeEntries').update(entry.id, { timeBlockId: blockId });
}
// 3. Migrate habit logs → timeBlocks
const logs = await tx.table('habitLogs').toArray();
const habitsById = new Map<string, Record<string, unknown>>();
const allHabits = await tx.table('habits').toArray();
for (const h of allHabits) habitsById.set(h.id as string, h);
for (const log of logs) {
if (!log.timestamp) continue;
const blockId = crypto.randomUUID();
const habit = habitsById.get(log.habitId as string);
await timeBlocksTable.add({
id: blockId,
startDate: log.timestamp,
endDate: null,
allDay: false,
isLive: false,
timezone: null,
recurrenceRule: null,
kind: 'logged',
type: 'habit',
sourceModule: 'habits',
sourceId: log.id,
linkedBlockId: null,
title: (habit?.title as string) ?? 'Habit',
description: null,
color: (habit?.color as string) ?? null,
icon: (habit?.icon as string) ?? null,
projectId: null,
createdAt: log.createdAt ?? new Date().toISOString(),
updatedAt: log.updatedAt ?? log.createdAt ?? new Date().toISOString(),
deletedAt: log.deletedAt ?? null,
});
await tx.table('habitLogs').update(log.id, { timeBlockId: blockId });
}
// 4. Migrate scheduled tasks → timeBlocks
const tasks = await tx.table('tasks').toArray();
for (const task of tasks) {
if (!task.scheduledDate) continue;
const blockId = crypto.randomUUID();
const startISO = task.scheduledStartTime
? `${task.scheduledDate}T${task.scheduledStartTime}:00`
: `${task.scheduledDate}T09:00:00`;
const durationMs = task.estimatedDuration ? task.estimatedDuration * 1000 : 3600000; // default 1h
const endISO = new Date(new Date(startISO).getTime() + durationMs).toISOString();
await timeBlocksTable.add({
id: blockId,
startDate: startISO,
endDate: endISO,
allDay: !task.scheduledStartTime,
isLive: false,
timezone: null,
recurrenceRule: null,
kind: 'scheduled',
type: 'task',
sourceModule: 'todo',
sourceId: task.id,
linkedBlockId: null,
title: task.title ?? '',
description: null,
color: null,
icon: null,
projectId: task.projectId ?? null,
createdAt: task.createdAt ?? new Date().toISOString(),
updatedAt: task.updatedAt ?? new Date().toISOString(),
deletedAt: task.deletedAt ?? null,
});
await tx.table('tasks').update(task.id, { scheduledBlockId: blockId });
}
});
// ─── Version 4: Recurrence instance fields on timeBlocks ──────
// Adds parentBlockId, recurrenceDate, isRecurrenceException indexes.
db.version(4).stores({
timeBlocks:
'id, startDate, kind, type, sourceModule, sourceId, parentBlockId, [sourceModule+sourceId], [type+startDate], [kind+startDate], [parentBlockId+recurrenceDate]',
});
// ─── Sync App Map ──────────────────────────────────────────
// Maps each table to its appId for sync routing.
// The SyncEngine uses this to group pending changes and push to /sync/{appId}.
export const SYNC_APP_MAP: Record<string, string[]> = {
mana: ['userSettings', 'dashboardConfigs', 'automations'],
todo: ['tasks', 'todoProjects', 'taskLabels', 'reminders', 'boardViews'],
calendar: ['calendars', 'events', 'eventTags'],
contacts: ['contacts', 'contactTags'],
chat: ['conversations', 'messages', 'chatTemplates', 'conversationTags'],
picture: ['images', 'boards', 'boardItems', 'imageTags'],
cards: ['cardDecks', 'cards', 'deckTags'],
zitare: ['zitareFavorites', 'zitareLists', 'zitareListTags'],
music: ['songs', 'mukkePlaylists', 'playlistSongs', 'mukkeProjects', 'markers', 'songTags'],
storage: ['files', 'storageFolders', 'fileTags'],
presi: ['presiDecks', 'slides', 'presiDeckTags'],
inventar: ['invCollections', 'invItems', 'invLocations', 'invCategories', 'invItemTags'],
photos: ['albums', 'albumItems', 'photoFavorites', 'photoMediaTags'],
skilltree: ['skills', 'activities', 'achievements', 'skillTags'],
citycorners: ['cities', 'ccLocations', 'ccFavorites', 'ccLocationTags'],
times: [
'timeClients',
'timeProjects',
'timeEntries',
'timeTemplates',
'timeSettings',
'timeAlarms',
'timeCountdownTimers',
'timeWorldClocks',
'entryTags',
],
context: ['contextSpaces', 'documents', 'documentTags'],
questions: ['qCollections', 'questions', 'answers', 'questionTags'],
nutriphi: ['meals', 'goals', 'nutriFavorites', 'mealTags'],
planta: ['plants', 'plantPhotos', 'wateringSchedules', 'wateringLogs', 'plantTags'],
uload: ['links', 'uloadTags', 'uloadFolders', 'linkTags'],
calc: ['calculations', 'savedFormulas'],
moodlit: ['moods', 'sequences', 'moodTags'],
memoro: ['memos', 'memories', 'memoTags', 'memoroSpaces', 'spaceMembers', 'memoSpaces'],
guides: ['guides', 'sections', 'steps', 'guideCollections', 'runs', 'guideTags'],
habits: ['habits', 'habitLogs'],
notes: ['notes', 'noteTags'],
finance: ['transactions', 'financeCategories', 'budgets'],
places: ['places', 'locationLogs', 'placeTags'],
tags: ['globalTags', 'tagGroups'],
links: ['manaLinks'],
timeblocks: ['timeBlocks', 'timeBlockTags'],
};
// ─── Reverse Map: Table → AppId ────────────────────────────
// Used by _pendingChanges to determine which appId to tag a change with.
export const TABLE_TO_APP: Record<string, string> = Object.fromEntries(
Object.entries(SYNC_APP_MAP).flatMap(([appId, tables]) => tables.map((table) => [table, appId]))
);
// ─── Table Name Mapping (Unified ↔ Backend) ──────────────────
// The unified DB renames tables to avoid collisions (e.g., todoProjects, cardDecks).
// The backend (mana-sync) knows the original names from standalone apps.
/** Unified table name → backend collection name (only renamed tables). */
export const TABLE_TO_SYNC_NAME: Record<string, string> = {
// todo
todoProjects: 'projects',
// chat
chatTemplates: 'templates',
// picture
// cards
cardDecks: 'decks',
// zitare
zitareFavorites: 'favorites',
zitareLists: 'lists',
// music
mukkePlaylists: 'playlists',
mukkeProjects: 'projects',
// storage
storageFolders: 'folders',
// presi
presiDecks: 'decks',
// inventar
invCollections: 'collections',
invItems: 'items',
invLocations: 'locations',
invCategories: 'categories',
// photos
photoFavorites: 'favorites',
photoMediaTags: 'photoTags',
// citycorners
ccLocations: 'locations',
ccFavorites: 'favorites',
// times
timeClients: 'clients',
timeProjects: 'projects',
timeTemplates: 'templates',
timeSettings: 'settings',
timeAlarms: 'alarms',
timeCountdownTimers: 'countdownTimers',
timeWorldClocks: 'worldClocks',
// context
contextSpaces: 'spaces',
// questions
qCollections: 'collections',
// nutriphi
nutriFavorites: 'favorites',
// memoro
memoroSpaces: 'spaces',
// uload
uloadTags: 'tags',
uloadFolders: 'folders',
// guides
guideCollections: 'collections',
// finance
financeCategories: 'categories',
// shared: tags
globalTags: 'tags',
tagGroups: 'tagGroups',
// shared: links
manaLinks: 'links',
};
/** Get the backend collection name for a unified table. */
export function toSyncName(tableName: string): string {
return TABLE_TO_SYNC_NAME[tableName] ?? tableName;
}
/** Build reverse map: for a given appId, maps backend collection name → unified table name. */
export const SYNC_NAME_TO_TABLE: Record<string, Record<string, string>> = {};
for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
const map: Record<string, string> = {};
for (const tableName of tables) {
const syncName = toSyncName(tableName);
map[syncName] = tableName;
}
SYNC_NAME_TO_TABLE[appId] = map;
}
/** Get the unified table name for a backend collection + appId. */
export function fromSyncName(appId: string, syncCollection: string): string {
return SYNC_NAME_TO_TABLE[appId]?.[syncCollection] ?? syncCollection;
}
// ─── Change Tracking via Dexie Hooks ─────────────────────────
// Automatically records pending changes for every write to sync-relevant tables.
// This means module stores (taskTable.add(), etc.) don't need manual trackChange() calls.
let _applyingServerChanges = false;
/** Set to true while applying server changes to prevent sync loops. */
export function setApplyingServerChanges(v: boolean): void {
_applyingServerChanges = v;
}
const pendingChangesTable = db.table('_pendingChanges');
for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
for (const tableName of tables) {
const table = db.table(tableName);
table.hook('creating', function (_primKey, obj) {
if (_applyingServerChanges) return;
const now = new Date().toISOString();
pendingChangesTable.add({
appId,
collection: tableName,
recordId: obj.id,
op: 'insert',
data: { ...obj },
createdAt: now,
});
trackFirstContent(appId);
fireTrigger(appId, tableName, 'insert', { ...obj });
// Defer cross-table reads outside the Dexie hook's transaction scope
const objCopy = { ...obj };
setTimeout(() => {
checkInlineSuggestion(appId, tableName, objCopy).then((sug) => {
if (sug)
window.dispatchEvent(new CustomEvent('mana:automation-suggest', { detail: sug }));
});
}, 0);
});
table.hook('updating', function (modifications, primKey) {
if (_applyingServerChanges) return;
const now = new Date().toISOString();
const fields: Record<string, { value: unknown; updatedAt: string }> = {};
for (const [key, value] of Object.entries(modifications)) {
if (key === 'id') continue;
fields[key] = { value, updatedAt: now };
}
pendingChangesTable.add({
appId,
collection: tableName,
recordId: primKey as string,
op: (modifications as Record<string, unknown>).deletedAt ? 'delete' : 'update',
fields,
deletedAt: (modifications as Record<string, unknown>).deletedAt as string | undefined,
createdAt: now,
});
const op = (modifications as Record<string, unknown>).deletedAt ? 'delete' : 'update';
fireTrigger(appId, tableName, op, modifications as Record<string, unknown>);
});
}
}

View file

@ -0,0 +1,16 @@
export * from './types';
export * from './collections';
export * from './service';
export * from './queries';
export * from './analytics';
export { generateICalendar, downloadICalendar } from './ical-export';
export {
expandRule,
habitScheduleToRRule,
rruleToHabitSchedule,
materializeRecurringBlocks,
regenerateForBlock,
cleanupFutureInstances,
deleteAllInstances,
expandTemplatesVirtually,
} from './recurrence';

View file

@ -0,0 +1,292 @@
/**
* Reactive Queries & Pure Helpers for TimeBlocks.
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes.
* Components call these hooks at init time; no manual fetch/refresh needed.
*
* Note: useLiveQueryWithDefault takes (querier, default) no deps array.
* For parameterized queries, use raw liveQuery from Dexie instead.
*/
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import type {
LocalTimeBlock,
TimeBlock,
TimeBlockKind,
TimeBlockType,
TimeBlockSourceModule,
} from './types';
import { isSameDay, isWithinInterval } from 'date-fns';
// ─── Type Converter ──────────────────────────────────────
export function toTimeBlock(local: LocalTimeBlock): TimeBlock {
return {
id: local.id,
startDate: local.startDate,
endDate: local.endDate ?? null,
allDay: local.allDay,
isLive: local.isLive,
timezone: local.timezone ?? null,
recurrenceRule: local.recurrenceRule ?? null,
kind: local.kind,
type: local.type,
sourceModule: local.sourceModule,
sourceId: local.sourceId,
linkedBlockId: local.linkedBlockId ?? null,
parentBlockId: local.parentBlockId ?? null,
recurrenceDate: local.recurrenceDate ?? null,
isRecurrenceException: local.isRecurrenceException ?? false,
title: local.title,
description: local.description ?? null,
color: local.color ?? null,
icon: local.icon ?? null,
projectId: local.projectId ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Svelte 5 Reactive Hooks ─────────────────────────────
/** All non-deleted timeBlocks. Auto-updates on change. */
export function useAllTimeBlocks() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalTimeBlock>('timeBlocks').toArray();
return locals.filter((b) => !b.deletedAt).map(toTimeBlock);
}, [] as TimeBlock[]);
}
/**
* All non-deleted timeBlocks within a date range.
* Returns a raw Dexie liveQuery observable (use with $-subscribe in Svelte).
*/
export function timeBlocksInRange$(start: string, end: string) {
return liveQuery(async () => {
const locals = await db
.table<LocalTimeBlock>('timeBlocks')
.where('startDate')
.between(start, end, true, true)
.toArray();
return locals.filter((b) => !b.deletedAt).map(toTimeBlock);
});
}
/** TimeBlock(s) for a specific source record (raw observable). */
export function timeBlocksBySource$(sourceModule: TimeBlockSourceModule, sourceId: string) {
return liveQuery(async () => {
const locals = await db
.table<LocalTimeBlock>('timeBlocks')
.where('[sourceModule+sourceId]')
.equals([sourceModule, sourceId])
.toArray();
return locals.filter((b) => !b.deletedAt).map(toTimeBlock);
});
}
/** The currently live/running timeBlock (if any). */
export function useLiveTimeBlock() {
return useLiveQueryWithDefault(
async () => {
// Can't index boolean in Dexie reliably, so scan and filter
const locals = await db.table<LocalTimeBlock>('timeBlocks').toArray();
const active = locals.find((b) => b.isLive && !b.deletedAt);
return active ? toTimeBlock(active) : null;
},
null as TimeBlock | null
);
}
// ─── Pure Helpers ─────────────────────────────────────────
/** Convert a date string or Date to a Date. */
function toDate(dateStr: string | Date): Date {
return typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
}
/** Get timeBlocks for a specific day. */
export function getBlocksForDay(blocks: TimeBlock[], date: Date): TimeBlock[] {
return blocks.filter((block) => {
const blockStart = toDate(block.startDate);
const blockEnd = block.endDate ? toDate(block.endDate) : blockStart;
if (block.allDay) {
return (
isWithinInterval(date, { start: blockStart, end: blockEnd }) || isSameDay(date, blockStart)
);
}
// Point events: match day of startDate
if (!block.endDate) {
return isSameDay(date, blockStart);
}
// Range events: any overlap with the day
const dayStart = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0);
const dayEnd = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999);
return blockStart <= dayEnd && blockEnd >= dayStart;
});
}
/** Get timeBlocks within a time range. */
export function getBlocksInRange(blocks: TimeBlock[], start: Date, end: Date): TimeBlock[] {
return blocks.filter((block) => {
const blockStart = toDate(block.startDate);
const blockEnd = block.endDate ? toDate(block.endDate) : blockStart;
return blockStart <= end && blockEnd >= start;
});
}
/** Sort timeBlocks by start time. */
export function sortBlocksByTime(blocks: TimeBlock[]): TimeBlock[] {
return [...blocks].sort(
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
);
}
/** Filter timeBlocks by kind. */
export function filterBlocksByKind(blocks: TimeBlock[], kind: TimeBlockKind): TimeBlock[] {
return blocks.filter((b) => b.kind === kind);
}
/** Filter timeBlocks by type. */
export function filterBlocksByType(blocks: TimeBlock[], type: TimeBlockType): TimeBlock[] {
return blocks.filter((b) => b.type === type);
}
/** Filter timeBlocks by visible types (for calendar filter toggles). */
export function filterBlocksByVisibleTypes(
blocks: TimeBlock[],
visibleTypes: Set<TimeBlockType>
): TimeBlock[] {
return blocks.filter((b) => visibleTypes.has(b.type));
}
/** Find overlapping timeBlocks for a given range. */
export function findOverlaps(
blocks: TimeBlock[],
start: string,
end: string,
excludeId?: string
): TimeBlock[] {
const rangeStart = new Date(start);
const rangeEnd = new Date(end);
return blocks.filter((block) => {
if (block.id === excludeId) return false;
if (block.allDay) return false;
const blockStart = new Date(block.startDate);
const blockEnd = block.endDate ? new Date(block.endDate) : blockStart;
return blockStart < rangeEnd && blockEnd > rangeStart;
});
}
/** Get the raw wall-clock duration in seconds (derived from start/end). */
export function getBlockDuration(block: TimeBlock): number {
if (!block.endDate) return 0;
return Math.max(
0,
(new Date(block.endDate).getTime() - new Date(block.startDate).getTime()) / 1000
);
}
/** Find free time slots on a given day. */
export function findFreeSlots(
blocks: TimeBlock[],
date: Date,
minDurationMinutes: number = 30,
workStart: number = 8,
workEnd: number = 18
): { start: Date; end: Date; durationMinutes: number }[] {
// Get non-allday blocks for the day, sorted by start
const dayBlocks = getBlocksForDay(blocks, date)
.filter((b) => !b.allDay && b.endDate)
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
const slots: { start: Date; end: Date; durationMinutes: number }[] = [];
const dayStart = new Date(date.getFullYear(), date.getMonth(), date.getDate(), workStart, 0, 0);
const dayEnd = new Date(date.getFullYear(), date.getMonth(), date.getDate(), workEnd, 0, 0);
let cursor = dayStart;
for (const block of dayBlocks) {
const blockStart = new Date(block.startDate);
const blockEnd = new Date(block.endDate!);
// Skip blocks outside working hours
if (blockEnd <= dayStart || blockStart >= dayEnd) continue;
const effectiveStart = blockStart < dayStart ? dayStart : blockStart;
if (cursor < effectiveStart) {
const gapMinutes = (effectiveStart.getTime() - cursor.getTime()) / 60000;
if (gapMinutes >= minDurationMinutes) {
slots.push({
start: new Date(cursor),
end: effectiveStart,
durationMinutes: Math.round(gapMinutes),
});
}
}
const effectiveEnd = blockEnd > dayEnd ? dayEnd : blockEnd;
if (effectiveEnd > cursor) {
cursor = effectiveEnd;
}
}
// Gap after last block until end of work
if (cursor < dayEnd) {
const gapMinutes = (dayEnd.getTime() - cursor.getTime()) / 60000;
if (gapMinutes >= minDurationMinutes) {
slots.push({ start: new Date(cursor), end: dayEnd, durationMinutes: Math.round(gapMinutes) });
}
}
return slots;
}
/** Find the next free slot across multiple days. */
export function findNextFreeSlot(
blocks: TimeBlock[],
minDurationMinutes: number = 60,
daysToSearch: number = 7,
workStart: number = 8,
workEnd: number = 18
): { start: Date; end: Date; durationMinutes: number } | null {
const today = new Date();
for (let d = 0; d < daysToSearch; d++) {
const date = new Date(today);
date.setDate(date.getDate() + d);
const slots = findFreeSlots(blocks, date, minDurationMinutes, workStart, workEnd);
if (slots.length > 0) {
// For today, skip slots that have already started
if (d === 0) {
const now = new Date();
const validSlot = slots.find((s) => s.start >= now);
if (validSlot) return validSlot;
} else {
return slots[0];
}
}
}
return null;
}
/** Group timeBlocks by date string (YYYY-MM-DD). */
export function groupBlocksByDate(blocks: TimeBlock[]): Map<string, TimeBlock[]> {
const map = new Map<string, TimeBlock[]>();
for (const block of blocks) {
const dateKey = block.startDate.split('T')[0];
const group = map.get(dateKey);
if (group) {
group.push(block);
} else {
map.set(dateKey, [block]);
}
}
return map;
}

View file

@ -0,0 +1,317 @@
/**
* Unified Recurrence Engine RRULE expansion + materialization.
*
* One system for all modules: Calendar events, Tasks, Habits.
* Uses rrule.js for RFC 5545 RRULE expansion.
*
* Hybrid approach:
* - Materialized: real TimeBlocks for the next 30 days (stored in DB)
* - Virtual: on-the-fly expansion for calendar views beyond 30 days
*/
import { RRule } from 'rrule';
import { db } from '$lib/data/database';
import { timeBlockTable } from './collections';
import { createBlock, deleteBlock } from './service';
import type { LocalTimeBlock } from './types';
import type { HabitSchedule } from '$lib/modules/habits/types';
// ─── RRULE Expansion ─────────────────────────────────────
/** Expand an RRULE string to concrete dates within a range. */
export function expandRule(rruleStr: string, dtstart: Date, rangeStart: Date, rangeEnd: Date): Date[] {
const rule = RRule.fromString(`DTSTART:${formatRRuleDate(dtstart)}\n${rruleStr}`);
return rule.between(rangeStart, rangeEnd, true);
}
/** Format a Date for RRULE DTSTART (YYYYMMDDTHHMMSSZ). */
function formatRRuleDate(date: Date): string {
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
}
// ─── HabitSchedule ↔ RRULE Conversion ────────────────────
const DAY_MAP: Record<number, string> = {
0: 'SU',
1: 'MO',
2: 'TU',
3: 'WE',
4: 'TH',
5: 'FR',
6: 'SA',
};
const REVERSE_DAY_MAP: Record<string, number> = {
SU: 0,
MO: 1,
TU: 2,
WE: 3,
TH: 4,
FR: 5,
SA: 6,
};
/** Convert a HabitSchedule to an RRULE string. */
export function habitScheduleToRRule(schedule: HabitSchedule): string {
if (schedule.days.length === 7) return 'RRULE:FREQ=DAILY';
const byDay = schedule.days.map((d) => DAY_MAP[d]).join(',');
return `RRULE:FREQ=WEEKLY;BYDAY=${byDay}`;
}
/** Convert an RRULE string back to a HabitSchedule (best-effort). */
export function rruleToHabitSchedule(rrule: string): HabitSchedule | null {
const clean = rrule.replace(/^RRULE:/, '');
if (clean.includes('FREQ=DAILY')) {
return { days: [0, 1, 2, 3, 4, 5, 6] };
}
const byDayMatch = clean.match(/BYDAY=([A-Z,]+)/);
if (!byDayMatch) return null;
const days = byDayMatch[1].split(',').map((d) => REVERSE_DAY_MAP[d]).filter((d) => d !== undefined);
return { days: days.sort() };
}
// ─── Materialization ─────────────────────────────────────
/**
* Materialize recurring TimeBlocks for the next N days.
* Creates real instances in the DB for each occurrence.
* Skips existing instances and exceptions.
*/
export async function materializeRecurringBlocks(daysAhead: number = 30): Promise<number> {
const allBlocks = await timeBlockTable.toArray();
// Find "template" blocks: have recurrenceRule, no parentBlockId (not instances themselves)
const templates = allBlocks.filter(
(b) => b.recurrenceRule && !b.deletedAt && !b.parentBlockId
);
if (templates.length === 0) return 0;
const today = new Date();
today.setHours(0, 0, 0, 0);
const horizon = new Date(today);
horizon.setDate(horizon.getDate() + daysAhead);
// Build set of existing instances to avoid duplicates
const existingInstances = allBlocks.filter((b) => b.parentBlockId && !b.deletedAt);
const existingKeys = new Set(
existingInstances.map((b) => `${b.parentBlockId}|${b.recurrenceDate}`)
);
let created = 0;
for (const template of templates) {
const dtstart = new Date(template.startDate);
const rruleStr = template.recurrenceRule!.replace(/^RRULE:/, '');
let dates: Date[];
try {
dates = expandRule(`RRULE:${rruleStr}`, dtstart, today, horizon);
} catch {
continue; // Skip invalid rules
}
// Calculate duration from template
const templateDurationMs = template.endDate
? new Date(template.endDate).getTime() - new Date(template.startDate).getTime()
: 3600000; // default 1h
// Extract time from template
const templateHours = dtstart.getHours();
const templateMinutes = dtstart.getMinutes();
for (const date of dates) {
const dateStr = date.toISOString().split('T')[0];
const key = `${template.id}|${dateStr}`;
if (existingKeys.has(key)) continue;
// Set time from template
const instanceStart = new Date(date);
instanceStart.setHours(templateHours, templateMinutes, 0, 0);
const instanceEnd = new Date(instanceStart.getTime() + templateDurationMs);
await createBlock({
startDate: instanceStart.toISOString(),
endDate: instanceEnd.toISOString(),
allDay: template.allDay,
kind: template.kind,
type: template.type,
sourceModule: template.sourceModule,
sourceId: template.sourceId,
title: template.title,
description: template.description ?? null,
color: template.color ?? null,
icon: template.icon ?? null,
projectId: template.projectId ?? null,
recurrenceRule: null, // instances don't have their own rule
});
// Set parentBlockId and recurrenceDate on the created block
// (createBlock doesn't support these fields directly, so update after)
const lastBlock = (await timeBlockTable.orderBy('createdAt').last())!;
await timeBlockTable.update(lastBlock.id, {
parentBlockId: template.id,
recurrenceDate: dateStr,
} as Partial<LocalTimeBlock>);
existingKeys.add(key);
created++;
}
}
return created;
}
/**
* Regenerate instances for a specific recurring block after rule change.
* Deletes future non-exception instances and re-materializes.
*/
export async function regenerateForBlock(
templateBlockId: string,
daysAhead: number = 30
): Promise<void> {
// Delete future non-exception instances
await cleanupFutureInstances(templateBlockId);
// Re-materialize
await materializeRecurringBlocks(daysAhead);
}
/**
* Delete future instances of a recurring block (preserving exceptions).
*/
export async function cleanupFutureInstances(templateBlockId: string): Promise<void> {
const today = new Date().toISOString().split('T')[0];
const instances = await timeBlockTable
.where('parentBlockId')
.equals(templateBlockId)
.toArray();
for (const instance of instances) {
if (instance.deletedAt) continue;
if ((instance as Record<string, unknown>).isRecurrenceException) continue;
if (instance.recurrenceDate && instance.recurrenceDate >= today) {
await deleteBlock(instance.id);
}
}
}
/**
* Delete all instances of a recurring block (including exceptions).
* Used when deleting the recurring event entirely.
*/
export async function deleteAllInstances(templateBlockId: string): Promise<void> {
const instances = await timeBlockTable
.where('parentBlockId')
.equals(templateBlockId)
.toArray();
for (const instance of instances) {
if (!instance.deletedAt) {
await deleteBlock(instance.id);
}
}
}
// ─── Virtual Expansion (for calendar views >30 days) ─────
export interface VirtualTimeBlock {
id: string; // synthetic: {parentId}__recurrence__{date}
parentBlockId: string;
recurrenceDate: string;
startDate: string;
endDate: string | null;
allDay: boolean;
isLive: false;
kind: string;
type: string;
sourceModule: string;
sourceId: string;
title: string;
description: string | null;
color: string | null;
icon: string | null;
projectId: string | null;
recurrenceRule: string | null;
linkedBlockId: null;
isVirtual: true;
createdAt: string;
updatedAt: string;
}
/**
* Expand recurring templates into virtual blocks for a date range.
* Only generates blocks that don't already exist as materialized instances.
*/
export function expandTemplatesVirtually(
templates: LocalTimeBlock[],
existingBlocks: LocalTimeBlock[],
rangeStart: string,
rangeEnd: string
): VirtualTimeBlock[] {
const existingKeys = new Set(
existingBlocks
.filter((b) => b.parentBlockId)
.map((b) => `${b.parentBlockId}|${(b as Record<string, unknown>).recurrenceDate}`)
);
const virtuals: VirtualTimeBlock[] = [];
const start = new Date(rangeStart);
const end = new Date(rangeEnd);
for (const template of templates) {
if (!template.recurrenceRule || template.deletedAt) continue;
const dtstart = new Date(template.startDate);
const rruleStr = template.recurrenceRule.replace(/^RRULE:/, '');
let dates: Date[];
try {
dates = expandRule(`RRULE:${rruleStr}`, dtstart, start, end);
} catch {
continue;
}
const durationMs = template.endDate
? new Date(template.endDate).getTime() - dtstart.getTime()
: 3600000;
const templateHours = dtstart.getHours();
const templateMinutes = dtstart.getMinutes();
for (const date of dates) {
const dateStr = date.toISOString().split('T')[0];
const key = `${template.id}|${dateStr}`;
if (existingKeys.has(key)) continue;
const instanceStart = new Date(date);
instanceStart.setHours(templateHours, templateMinutes, 0, 0);
const instanceEnd = new Date(instanceStart.getTime() + durationMs);
virtuals.push({
id: `${template.id}__recurrence__${dateStr}`,
parentBlockId: template.id,
recurrenceDate: dateStr,
startDate: instanceStart.toISOString(),
endDate: instanceEnd.toISOString(),
allDay: template.allDay,
isLive: false,
kind: template.kind,
type: template.type,
sourceModule: template.sourceModule,
sourceId: template.sourceId,
title: template.title,
description: template.description ?? null,
color: template.color ?? null,
icon: template.icon ?? null,
projectId: template.projectId ?? null,
recurrenceRule: template.recurrenceRule,
linkedBlockId: null,
isVirtual: true,
createdAt: template.createdAt ?? new Date().toISOString(),
updatedAt: template.updatedAt ?? new Date().toISOString(),
});
}
}
return virtuals;
}

View file

@ -0,0 +1,118 @@
/**
* Unified Time Model TimeBlock types.
*
* A TimeBlock represents any time interval across all modules.
* Domain-specific data stays on each module's tables; the TimeBlock
* owns the time dimension (start, end, recurrence, live status).
*/
import type { BaseRecord } from '@mana/local-store';
// ─── Enums ───────────────────────────────────────────────
export type TimeBlockKind = 'scheduled' | 'logged';
export type TimeBlockType = 'event' | 'task' | 'habit' | 'timeEntry' | 'focus' | 'break';
export type TimeBlockSourceModule = 'calendar' | 'todo' | 'times' | 'habits';
// ─── Local Record Types (Dexie) ──────────────────────────
export interface LocalTimeBlock extends BaseRecord {
// Time
startDate: string; // ISO — always set
endDate: string | null; // ISO — null = point-event or live timer
allDay: boolean;
isLive: boolean; // timer/tracking currently running
timezone?: string | null;
recurrenceRule?: string | null;
// Classification
kind: TimeBlockKind;
type: TimeBlockType;
// Link to source module
sourceModule: TimeBlockSourceModule;
sourceId: string;
linkedBlockId?: string | null; // scheduled → logged link
// Recurrence instance fields
parentBlockId?: string | null; // ID of the recurring "template" block
recurrenceDate?: string | null; // YYYY-MM-DD this instance represents
isRecurrenceException?: boolean; // user manually edited this instance
// Display (denormalized for calendar rendering without joins)
title: string;
description?: string | null;
color?: string | null;
icon?: string | null;
projectId?: string | null;
}
export interface LocalTimeBlockTag extends BaseRecord {
blockId: string;
tagId: string;
}
// ─── Domain Types (returned by queries, used by UI) ──────
export interface TimeBlock {
id: string;
startDate: string;
endDate: string | null;
allDay: boolean;
isLive: boolean;
timezone: string | null;
recurrenceRule: string | null;
kind: TimeBlockKind;
type: TimeBlockType;
sourceModule: TimeBlockSourceModule;
sourceId: string;
linkedBlockId: string | null;
parentBlockId: string | null;
recurrenceDate: string | null;
isRecurrenceException: boolean;
title: string;
description: string | null;
color: string | null;
icon: string | null;
projectId: string | null;
createdAt: string;
updatedAt: string;
}
// ─── Input Types ─────────────────────────────────────────
export interface CreateTimeBlockInput {
startDate: string;
endDate?: string | null;
allDay?: boolean;
isLive?: boolean;
timezone?: string | null;
recurrenceRule?: string | null;
kind: TimeBlockKind;
type: TimeBlockType;
sourceModule: TimeBlockSourceModule;
sourceId: string;
linkedBlockId?: string | null;
title: string;
description?: string | null;
color?: string | null;
icon?: string | null;
projectId?: string | null;
}
export interface UpdateTimeBlockInput {
startDate?: string;
endDate?: string | null;
allDay?: boolean;
isLive?: boolean;
timezone?: string | null;
recurrenceRule?: string | null;
linkedBlockId?: string | null;
title?: string;
description?: string | null;
color?: string | null;
icon?: string | null;
projectId?: string | null;
}

View file

@ -0,0 +1,352 @@
<script lang="ts">
import type { CalendarEvent } from '../types';
import { eventsStore } from '../stores/events.svelte';
import { CheckSquare, Clock, Timer, Lightning, CheckCircle, ArrowsClockwise } from '@mana/shared-icons';
import { getIconComponent } from '@mana/shared-icons';
interface Props {
event: CalendarEvent;
style: string;
color: string;
isDragging?: boolean;
isResizing?: boolean;
isDraggingSource?: boolean;
formattedTime: string;
onClick?: (event: CalendarEvent, e: MouseEvent) => void;
onPointerDown?: (event: CalendarEvent, e: PointerEvent) => void;
onContextMenu?: (event: CalendarEvent, e: MouseEvent) => void;
onResizeStart?: (event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) => void;
}
let {
event,
style,
color,
isDragging = false,
isResizing = false,
isDraggingSource = false,
formattedTime,
onClick,
onPointerDown,
onContextMenu,
onResizeStart,
}: Props = $props();
let isDraft = $derived(eventsStore.isDraftEvent(event.id));
/** Resolve the Phosphor icon component for habit blocks. */
let habitIconComponent = $derived(
event.blockType === 'habit' && event.icon ? getIconComponent(event.icon) : null
);
function handleClick(e: MouseEvent) {
if (isDragging || isResizing || isDraft) {
e.preventDefault();
e.stopPropagation();
return;
}
onClick?.(event, e);
}
function handlePointerDown(e: PointerEvent) {
onPointerDown?.(event, e);
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (isDraft) return;
onContextMenu?.(event, e);
}
function handleResizeTop(e: PointerEvent) {
e.stopPropagation();
onResizeStart?.(event, 'top', e);
}
function handleResizeBottom(e: PointerEvent) {
e.stopPropagation();
onResizeStart?.(event, 'bottom', e);
}
function handleKeydown(e: KeyboardEvent) {
if ((e.key === 'Enter' || e.key === ' ') && !isDraft) {
e.preventDefault();
onClick?.(event, e as unknown as MouseEvent);
}
}
</script>
<div
class="event-card block-type-{event.blockType}"
class:dragging={isDragging && !isDraggingSource}
class:dragging-source={isDraggingSource}
class:resizing={isResizing}
class:draft={isDraft}
class:live={event.isLive}
data-event-id={event.id}
{style}
style:background-color={color}
role="button"
tabindex="0"
aria-label={event.title || 'Neuer Termin'}
onpointerdown={handlePointerDown}
onclick={handleClick}
onkeydown={handleKeydown}
oncontextmenu={handleContextMenu}
>
{#if onResizeStart}
<div
class="resize-handle top"
onpointerdown={handleResizeTop}
role="slider"
aria-label="Startzeit ändern"
aria-valuenow={0}
tabindex="-1"
></div>
{/if}
<div class="event-header-row">
<!-- Type icon -->
{#if event.blockType === 'task'}
<span class="type-icon"><CheckSquare size={10} weight="bold" /></span>
{:else if event.blockType === 'timeEntry'}
<span class="type-icon"><Timer size={10} weight="bold" /></span>
{:else if event.blockType === 'habit' && habitIconComponent}
<span class="type-icon">
<svelte:component this={habitIconComponent} size={10} weight="bold" />
</span>
{:else if event.blockType === 'focus'}
<span class="type-icon"><Lightning size={10} weight="bold" /></span>
{/if}
<span class="event-time">{formattedTime}</span>
{#if event.parentBlockId}
<span class="repeat-icon" title="Wiederkehrend"><ArrowsClockwise size={9} /></span>
{/if}
{#if event.linkedBlockId}
<span class="linked-badge" title="Durchgeführt"><CheckCircle size={10} weight="fill" /></span>
{/if}
</div>
<span class="event-title">{event.title || (isDraft ? 'Neuer Termin' : '')}</span>
{#if event.location}
<span class="event-location">{event.location}</span>
{/if}
{#if onResizeStart}
<div
class="resize-handle bottom"
onpointerdown={handleResizeBottom}
role="slider"
aria-label="Endzeit ändern"
aria-valuenow={0}
tabindex="-1"
></div>
{/if}
</div>
<style>
.event-card {
position: absolute;
left: 2px;
right: 2px;
padding: 2px 4px;
border-radius: var(--radius-sm, 4px);
text-align: left;
cursor: grab;
z-index: 1;
overflow: hidden;
transition:
box-shadow 0.15s ease,
opacity 0.15s ease;
touch-action: none;
user-select: none;
color: white;
}
.event-card:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.event-card:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
}
/* ─── Block-type visual differentiation ─── */
.event-card.block-type-task {
border-left: 3px solid rgba(255, 255, 255, 0.6);
}
.event-card.block-type-habit {
border-radius: var(--radius-sm, 4px) 8px 8px var(--radius-sm, 4px);
}
.event-card.block-type-timeEntry {
border-style: solid;
border-width: 1px;
border-color: rgba(255, 255, 255, 0.3);
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 4px,
rgba(255, 255, 255, 0.05) 4px,
rgba(255, 255, 255, 0.05) 8px
);
}
.event-card.block-type-focus {
border: 2px dashed rgba(255, 255, 255, 0.5);
}
/* Live/running indicator */
.event-card.live {
animation: pulse-border 2s ease-in-out infinite;
}
@keyframes pulse-border {
0%,
100% {
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.4);
}
50% {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1);
}
}
/* ─── Drag/resize states ─── */
.event-card.dragging {
cursor: grabbing;
opacity: 0.9;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
z-index: 100;
}
.event-card.resizing {
opacity: 0.85;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
outline: 2px dashed hsl(var(--color-primary) / 0.6);
outline-offset: -2px;
}
.event-card.dragging-source {
opacity: 0.4;
background: transparent !important;
border: 2px dashed hsl(var(--color-border));
pointer-events: none;
}
.event-card.dragging-source .event-title,
.event-card.dragging-source .event-time,
.event-card.dragging-source .event-location {
opacity: 0.5;
}
.event-card.draft {
border: 2px dashed hsl(var(--color-primary) / 0.6);
background-color: hsl(var(--color-primary) / 0.3) !important;
}
/* ─── Content ─── */
.event-header-row {
display: flex;
align-items: center;
gap: 3px;
}
.type-icon {
display: flex;
align-items: center;
opacity: 0.85;
flex-shrink: 0;
}
.repeat-icon {
display: flex;
align-items: center;
opacity: 0.6;
flex-shrink: 0;
}
.linked-badge {
display: flex;
align-items: center;
opacity: 0.9;
color: rgba(255, 255, 255, 0.95);
margin-left: auto;
flex-shrink: 0;
}
.event-time {
display: block;
font-size: 0.6rem;
opacity: 0.9;
}
.event-title {
display: block;
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-location {
display: block;
font-size: 0.6rem;
opacity: 0.85;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ─── Resize handles ─── */
.resize-handle {
position: absolute;
left: 0;
right: 0;
height: 20px;
cursor: ns-resize;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
}
.resize-handle::after {
content: '';
width: 32px;
height: 4px;
background: rgba(255, 255, 255, 0.5);
border-radius: 2px;
}
.resize-handle.top {
top: -6px;
border-radius: var(--radius-sm, 4px) var(--radius-sm, 4px) 0 0;
}
.resize-handle.bottom {
bottom: -6px;
border-radius: 0 0 var(--radius-sm, 4px) var(--radius-sm, 4px);
}
.event-card:hover .resize-handle,
.event-card.resizing .resize-handle {
opacity: 1;
background: rgba(255, 255, 255, 0.15);
}
.event-card:hover .resize-handle::after,
.event-card.resizing .resize-handle::after {
background: rgba(255, 255, 255, 0.7);
}
</style>

View file

@ -0,0 +1,111 @@
/**
* Calendar module types for the unified Mana app.
*
* Time fields (startDate, endDate, allDay, recurrenceRule) live on TimeBlock.
* LocalEvent only stores calendar-domain data + a timeBlockId reference.
*/
import type { BaseRecord } from '@mana/local-store';
import type { TimeBlock, TimeBlockType } from '$lib/data/time-blocks/types';
export interface LocalCalendar extends BaseRecord {
name: string;
color: string;
isDefault: boolean;
isVisible: boolean;
timezone: string;
}
export interface LocalEvent extends BaseRecord {
calendarId: string;
timeBlockId: string;
title: string;
description?: string | null;
location?: string | null;
color?: string | null;
reminders?: unknown | null;
tagIds?: string[];
}
export type CalendarViewType = 'week' | 'month' | 'agenda';
/**
* CalendarEvent the UI-facing type used by all calendar components.
* Combines LocalEvent domain data with TimeBlock time data.
*/
export interface CalendarEvent {
id: string;
calendarId: string;
timeBlockId: string;
title: string;
description: string | null;
location: string | null;
startTime: string;
endTime: string;
isAllDay: boolean;
timezone: string | null;
recurrenceRule: string | null;
parentEventId: string | null;
color: string | null;
tagIds: string[];
createdAt: string;
updatedAt: string;
// TimeBlock metadata (for universal calendar view)
blockType: TimeBlockType;
sourceModule: string;
sourceId: string;
icon: string | null;
isLive: boolean;
projectId: string | null;
linkedBlockId: string | null;
parentBlockId: string | null;
recurrenceRule: string | null;
}
export interface Calendar {
id: string;
name: string;
color: string;
isDefault: boolean;
isVisible: boolean;
timezone: string;
createdAt: string;
updatedAt: string;
}
/**
* Construct a CalendarEvent from a TimeBlock.
* For native calendar events, also merges LocalEvent domain data.
* For cross-module blocks (tasks, habits, time entries), uses TimeBlock display fields.
*/
export function timeBlockToCalendarEvent(
block: TimeBlock,
eventData?: LocalEvent | null
): CalendarEvent {
return {
id: eventData?.id ?? block.sourceId,
calendarId: eventData?.calendarId ?? '__external__',
timeBlockId: block.id,
title: eventData?.title ?? block.title,
description: eventData?.description ?? block.description ?? null,
location: eventData?.location ?? null,
startTime: block.startDate,
endTime: block.endDate ?? block.startDate,
isAllDay: block.allDay,
timezone: block.timezone,
recurrenceRule: block.recurrenceRule,
parentEventId: null,
color: eventData?.color ?? block.color,
tagIds: eventData?.tagIds ?? [],
createdAt: block.createdAt,
updatedAt: block.updatedAt,
blockType: block.type,
sourceModule: block.sourceModule,
sourceId: block.sourceId,
icon: block.icon,
isLive: block.isLive,
projectId: block.projectId,
linkedBlockId: block.linkedBlockId,
parentBlockId: block.parentBlockId,
};
}

View file

@ -0,0 +1,124 @@
/**
* Todo module types for the unified app.
*/
import type { BaseRecord } from '@mana/local-store';
import type { Tag } from '@mana/shared-tags';
/** @deprecated Use Tag from @mana/shared-tags. Kept for backward compatibility. */
export type LocalLabel = Tag;
// ─── Local Types (IndexedDB) ──────────────────────────────
export interface Subtask {
id: string;
title: string;
isCompleted: boolean;
completedAt?: string | null;
order: number;
}
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
export interface LocalTask extends BaseRecord {
title: string;
description?: string;
userId?: string;
projectId?: string | null;
priority: TaskPriority;
isCompleted: boolean;
completedAt?: string | null;
dueDate?: string | null;
scheduledBlockId?: string | null; // TimeBlock ID when task is scheduled on calendar
estimatedDuration?: number | null;
order: number;
// recurrenceRule lives on the TimeBlock (via scheduledBlockId)
subtasks?: Subtask[] | null;
metadata?: Record<string, unknown>;
}
export interface LocalTaskTag extends BaseRecord {
taskId: string;
tagId: string;
}
export interface LocalReminder extends BaseRecord {
taskId: string;
userId?: string;
minutesBefore: number;
type: 'push' | 'email' | 'both';
status: 'pending' | 'sent' | 'failed';
}
// ─── Board Views ────────────────────────────────────────────
export interface TaskMatcher {
type: 'status' | 'priority' | 'tag' | 'dueDate' | 'custom';
value?: string | null;
taskIds?: string[];
}
export interface DropAction {
setCompleted?: boolean;
setPriority?: TaskPriority;
}
export interface ViewColumn {
id: string;
name: string;
color: string;
match: TaskMatcher;
onDrop?: DropAction;
}
export interface ViewFilter {
tagIds?: string[];
priorities?: string[];
}
export interface LocalBoardView extends BaseRecord {
name: string;
icon: string;
groupBy: 'status' | 'priority' | 'dueDate' | 'tag' | 'custom';
columns: ViewColumn[];
filter?: ViewFilter;
layout: 'kanban' | 'grid' | 'fokus';
order: number;
}
export interface LocalTodoProject extends BaseRecord {
name: string;
color?: string | null;
icon?: string | null;
order: number;
isArchived?: boolean;
isDefault?: boolean;
}
// ─── Shared Task Type ──────────────────────────────────────
export interface Task {
id: string;
projectId?: string | null;
userId: string;
title: string;
description?: string | null;
dueDate?: string | null;
scheduledBlockId?: string | null;
estimatedDuration?: number | null;
priority: TaskPriority;
status: TaskStatus;
isCompleted: boolean;
completedAt?: string | null;
order: number;
// recurrenceRule lives on the TimeBlock (via scheduledBlockId)
subtasks?: Subtask[] | null;
metadata?: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
}
export type ViewType = 'inbox' | 'today' | 'upcoming' | 'label' | 'completed' | 'search';
export type SortBy = 'dueDate' | 'priority' | 'title' | 'createdAt' | 'order';
export type SortOrder = 'asc' | 'desc';

View file

@ -0,0 +1,28 @@
<script lang="ts">
import { setContext, onMount } from 'svelte';
import type { Snippet } from 'svelte';
import { useAllCalendars, useAllCalendarItems } from '$lib/modules/calendar/queries';
import { calendarViewStore } from '$lib/modules/calendar/stores/view.svelte';
import { materializeRecurringBlocks } from '$lib/data/time-blocks/recurrence';
let { children }: { children: Snippet } = $props();
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
const allCalendars = useAllCalendars();
const allCalendarItems = useAllCalendarItems();
// Provide data to child components via Svelte context
// calendarEvents now contains ALL timeBlock types (events, tasks, habits, timeEntries)
setContext('calendars', allCalendars);
setContext('calendarEvents', allCalendarItems);
// Initialize view preferences
calendarViewStore.initialize();
// Materialize recurring blocks for the next 30 days
onMount(() => {
materializeRecurringBlocks(30);
});
</script>
{@render children()}

1895
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff