mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
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:
parent
98ec6c3cbb
commit
a787a27daa
11 changed files with 3263 additions and 683 deletions
84
apps/mana/apps/web/package.json
Normal file
84
apps/mana/apps/web/package.json
Normal 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"
|
||||
}
|
||||
609
apps/mana/apps/web/src/lib/data/database.ts
Normal file
609
apps/mana/apps/web/src/lib/data/database.ts
Normal 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>);
|
||||
});
|
||||
}
|
||||
}
|
||||
16
apps/mana/apps/web/src/lib/data/time-blocks/index.ts
Normal file
16
apps/mana/apps/web/src/lib/data/time-blocks/index.ts
Normal 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';
|
||||
292
apps/mana/apps/web/src/lib/data/time-blocks/queries.ts
Normal file
292
apps/mana/apps/web/src/lib/data/time-blocks/queries.ts
Normal 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;
|
||||
}
|
||||
317
apps/mana/apps/web/src/lib/data/time-blocks/recurrence.ts
Normal file
317
apps/mana/apps/web/src/lib/data/time-blocks/recurrence.ts
Normal 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;
|
||||
}
|
||||
118
apps/mana/apps/web/src/lib/data/time-blocks/types.ts
Normal file
118
apps/mana/apps/web/src/lib/data/time-blocks/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
111
apps/mana/apps/web/src/lib/modules/calendar/types.ts
Normal file
111
apps/mana/apps/web/src/lib/modules/calendar/types.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
124
apps/mana/apps/web/src/lib/modules/todo/types.ts
Normal file
124
apps/mana/apps/web/src/lib/modules/todo/types.ts
Normal 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';
|
||||
28
apps/mana/apps/web/src/routes/(app)/calendar/+layout.svelte
Normal file
28
apps/mana/apps/web/src/routes/(app)/calendar/+layout.svelte
Normal 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
1895
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue