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