From 29ad31c4edadc8fd131674993e1885b74f3f06f5 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 10 Apr 2026 19:07:59 +0200 Subject: [PATCH] feat(timeblocks): integrate guides, places, cards modules Extend the unified TimeBlock system to 3 more modules. New TimeBlockTypes: guide, visit, study New SourceModules: guides, places, cards - guides: startRun() creates 'guide' block, completeRun() stamps endDate - places: recordVisit() + auto-visit tracking create 'visit' point-events - cards: add startStudySession/endStudySession with live 'study' blocks - Update analytics colors/labels, calendar filters, dashboard widgets Co-Authored-By: Claude Opus 4.6 (1M context) --- .../widgets/ActivityFeedWidget.svelte | 6 +++ .../widgets/DayTimelineWidget.svelte | 6 +++ .../web/src/lib/data/time-blocks/analytics.ts | 6 +++ .../web/src/lib/data/time-blocks/types.ts | 10 +++- .../calendar/components/CalendarHeader.svelte | 6 +++ .../modules/calendar/stores/view.svelte.ts | 3 ++ .../lib/modules/cards/stores/decks.svelte.ts | 51 ++++++++++++++++++- .../apps/web/src/lib/modules/cards/types.ts | 1 + .../modules/guides/stores/guides.svelte.ts | 40 +++++++++++++-- .../apps/web/src/lib/modules/guides/types.ts | 1 + .../modules/places/stores/places.svelte.ts | 22 ++++++-- .../modules/places/stores/tracking.svelte.ts | 14 ++++- 12 files changed, 154 insertions(+), 12 deletions(-) diff --git a/apps/mana/apps/web/src/lib/components/dashboard/widgets/ActivityFeedWidget.svelte b/apps/mana/apps/web/src/lib/components/dashboard/widgets/ActivityFeedWidget.svelte index d29458af0..11e590db2 100644 --- a/apps/mana/apps/web/src/lib/components/dashboard/widgets/ActivityFeedWidget.svelte +++ b/apps/mana/apps/web/src/lib/components/dashboard/widgets/ActivityFeedWidget.svelte @@ -26,6 +26,9 @@ Moon, GraduationCap, FlowerLotus, + Compass, + MapPin, + BookOpen, } from '@mana/shared-icons'; import { getIconComponent } from '@mana/shared-icons'; import { formatDistanceToNow } from 'date-fns'; @@ -57,6 +60,9 @@ sleep: Moon, practice: GraduationCap, cycle: FlowerLotus, + guide: Compass, + visit: MapPin, + study: BookOpen, }; function timeAgo(iso: string): string { diff --git a/apps/mana/apps/web/src/lib/components/dashboard/widgets/DayTimelineWidget.svelte b/apps/mana/apps/web/src/lib/components/dashboard/widgets/DayTimelineWidget.svelte index 5983f4d5a..584433df8 100644 --- a/apps/mana/apps/web/src/lib/components/dashboard/widgets/DayTimelineWidget.svelte +++ b/apps/mana/apps/web/src/lib/components/dashboard/widgets/DayTimelineWidget.svelte @@ -25,6 +25,9 @@ Moon, GraduationCap, FlowerLotus, + Compass, + MapPin, + BookOpen, } from '@mana/shared-icons'; import { getIconComponent } from '@mana/shared-icons'; import { format } from 'date-fns'; @@ -72,6 +75,9 @@ sleep: { icon: Moon, label: 'Schlaf' }, practice: { icon: GraduationCap, label: 'Übung' }, cycle: { icon: FlowerLotus, label: 'Zyklus' }, + guide: { icon: Compass, label: 'Guide' }, + visit: { icon: MapPin, label: 'Besuch' }, + study: { icon: BookOpen, label: 'Lernen' }, }; function formatBlockTime(block: TimeBlock): string { diff --git a/apps/mana/apps/web/src/lib/data/time-blocks/analytics.ts b/apps/mana/apps/web/src/lib/data/time-blocks/analytics.ts index f8dd95a7f..c8146dd08 100644 --- a/apps/mana/apps/web/src/lib/data/time-blocks/analytics.ts +++ b/apps/mana/apps/web/src/lib/data/time-blocks/analytics.ts @@ -30,6 +30,9 @@ const TYPE_COLORS: Record = { sleep: '#6366f1', practice: '#f97316', cycle: '#ec4899', + guide: '#14b8a6', + visit: '#a855f7', + study: '#0ea5e9', }; const TYPE_LABELS: Record = { @@ -44,6 +47,9 @@ const TYPE_LABELS: Record = { sleep: 'Schlaf', practice: 'Übung', cycle: 'Zyklus', + guide: 'Guides', + visit: 'Besuche', + study: 'Lernen', }; function blockDuration(b: TimeBlock): number { diff --git a/apps/mana/apps/web/src/lib/data/time-blocks/types.ts b/apps/mana/apps/web/src/lib/data/time-blocks/types.ts index b9237f331..09bee167a 100644 --- a/apps/mana/apps/web/src/lib/data/time-blocks/types.ts +++ b/apps/mana/apps/web/src/lib/data/time-blocks/types.ts @@ -23,7 +23,10 @@ export type TimeBlockType = | 'watering' | 'sleep' | 'practice' - | 'cycle'; + | 'cycle' + | 'guide' + | 'visit' + | 'study'; export type TimeBlockSourceModule = | 'calendar' @@ -35,7 +38,10 @@ export type TimeBlockSourceModule = | 'planta' | 'dreams' | 'skilltree' - | 'cycles'; + | 'cycles' + | 'guides' + | 'places' + | 'cards'; // ─── Local Record Types (Dexie) ────────────────────────── diff --git a/apps/mana/apps/web/src/lib/modules/calendar/components/CalendarHeader.svelte b/apps/mana/apps/web/src/lib/modules/calendar/components/CalendarHeader.svelte index 629c66297..9fd922d70 100644 --- a/apps/mana/apps/web/src/lib/modules/calendar/components/CalendarHeader.svelte +++ b/apps/mana/apps/web/src/lib/modules/calendar/components/CalendarHeader.svelte @@ -17,6 +17,9 @@ Moon, GraduationCap, FlowerLotus, + Compass, + MapPin, + BookOpen, } from '@mana/shared-icons'; import { db } from '$lib/data/database'; import { decryptRecords } from '$lib/data/crypto'; @@ -44,6 +47,9 @@ { type: 'sleep', label: 'Schlaf', icon: Moon }, { type: 'practice', label: 'Übung', icon: GraduationCap }, { type: 'cycle', label: 'Zyklus', icon: FlowerLotus }, + { type: 'guide', label: 'Guides', icon: Compass }, + { type: 'visit', label: 'Besuche', icon: MapPin }, + { type: 'study', label: 'Lernen', icon: BookOpen }, ]; let allActive = $derived( diff --git a/apps/mana/apps/web/src/lib/modules/calendar/stores/view.svelte.ts b/apps/mana/apps/web/src/lib/modules/calendar/stores/view.svelte.ts index c5ac68752..9b60f735d 100644 --- a/apps/mana/apps/web/src/lib/modules/calendar/stores/view.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/calendar/stores/view.svelte.ts @@ -36,6 +36,9 @@ let visibleBlockTypes = $state>( 'sleep', 'practice', 'cycle', + 'guide', + 'visit', + 'study', ]) ); diff --git a/apps/mana/apps/web/src/lib/modules/cards/stores/decks.svelte.ts b/apps/mana/apps/web/src/lib/modules/cards/stores/decks.svelte.ts index 0b5eb378d..5775245e7 100644 --- a/apps/mana/apps/web/src/lib/modules/cards/stores/decks.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/cards/stores/decks.svelte.ts @@ -9,7 +9,8 @@ import { CardsEvents } from '@mana/shared-utils/analytics'; import { db } from '$lib/data/database'; import { cardDeckTable, cardTable } from '../collections'; import { toDeck } from '../queries'; -import { encryptRecord } from '$lib/data/crypto'; +import { encryptRecord, decryptRecord } from '$lib/data/crypto'; +import { createBlock, updateBlock } from '$lib/data/time-blocks/service'; import type { LocalDeck } from '../types'; import type { Deck, CreateDeckInput, UpdateDeckInput } from '../types'; @@ -86,6 +87,54 @@ export const deckStore = { } }, + async startStudySession(deckId: string): Promise { + const deck = await cardDeckTable.get(deckId); + if (!deck) return null; + + // Don't start a second session if one is already active + if (deck.activeStudyBlockId) return deck.activeStudyBlockId; + + const decrypted = await decryptRecord('cardDecks', { ...deck }); + const deckName = decrypted?.name ?? 'Deck'; + const now = new Date().toISOString(); + + const timeBlockId = await createBlock({ + startDate: now, + endDate: null, + isLive: true, + kind: 'logged', + type: 'study', + sourceModule: 'cards', + sourceId: deckId, + title: `${deckName} lernen`, + color: '#0ea5e9', + }); + + await cardDeckTable.update(deckId, { + activeStudyBlockId: timeBlockId, + lastStudied: now, + updatedAt: now, + }); + + return timeBlockId; + }, + + async endStudySession(deckId: string): Promise { + const deck = await cardDeckTable.get(deckId); + if (!deck?.activeStudyBlockId) return; + + const now = new Date().toISOString(); + await updateBlock(deck.activeStudyBlockId, { + endDate: now, + isLive: false, + }); + + await cardDeckTable.update(deckId, { + activeStudyBlockId: null, + updatedAt: now, + }); + }, + clearError() { error = null; }, diff --git a/apps/mana/apps/web/src/lib/modules/cards/types.ts b/apps/mana/apps/web/src/lib/modules/cards/types.ts index 03b333d0e..dec587a16 100644 --- a/apps/mana/apps/web/src/lib/modules/cards/types.ts +++ b/apps/mana/apps/web/src/lib/modules/cards/types.ts @@ -11,6 +11,7 @@ export interface LocalDeck extends BaseRecord { cardCount: number; lastStudied?: string | null; isPublic: boolean; + activeStudyBlockId?: string | null; } export interface LocalCard extends BaseRecord { diff --git a/apps/mana/apps/web/src/lib/modules/guides/stores/guides.svelte.ts b/apps/mana/apps/web/src/lib/modules/guides/stores/guides.svelte.ts index 1f0cc9423..977c57670 100644 --- a/apps/mana/apps/web/src/lib/modules/guides/stores/guides.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/guides/stores/guides.svelte.ts @@ -8,7 +8,8 @@ import { guideTable, sectionTable, stepTable, runTable } from '../collections'; import { toGuide, toSection, toStep, toRun } from '../queries'; -import { encryptRecord } from '$lib/data/crypto'; +import { encryptRecord, decryptRecord } from '$lib/data/crypto'; +import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service'; import type { LocalGuide, LocalSection, @@ -150,12 +151,32 @@ export const guidesStore = { // ─── Runs (Progress Tracking) ──────────────────────── async startRun(guideId: string): Promise { + // Resolve guide title for the TimeBlock + const guide = await guideTable.get(guideId); + const decryptedGuide = guide ? await decryptRecord('guides', { ...guide }) : null; + const guideTitle = decryptedGuide?.title ?? 'Guide'; + + const runId = crypto.randomUUID(); + const now = new Date().toISOString(); + + const timeBlockId = await createBlock({ + startDate: now, + endDate: null, + kind: 'logged', + type: 'guide', + sourceModule: 'guides', + sourceId: runId, + title: guideTitle, + color: '#14b8a6', + }); + const newLocal: LocalRun = { - id: crypto.randomUUID(), + id: runId, guideId, - startedAt: new Date().toISOString(), + startedAt: now, completedAt: null, completedStepIds: [], + timeBlockId, }; const snapshot = toRun({ ...newLocal }); await runTable.add(newLocal); @@ -182,14 +203,23 @@ export const guidesStore = { }, async completeRun(runId: string): Promise { + const now = new Date().toISOString(); + const run = await runTable.get(runId); await runTable.update(runId, { - completedAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + completedAt: now, + updatedAt: now, }); + if (run?.timeBlockId) { + await updateBlock(run.timeBlockId, { endDate: now }); + } }, async deleteRun(id: string): Promise { + const run = await runTable.get(id); const now = new Date().toISOString(); + if (run?.timeBlockId) { + await deleteBlock(run.timeBlockId); + } await runTable.update(id, { deletedAt: now, updatedAt: now }); }, }; diff --git a/apps/mana/apps/web/src/lib/modules/guides/types.ts b/apps/mana/apps/web/src/lib/modules/guides/types.ts index 137435702..bd45bd27d 100644 --- a/apps/mana/apps/web/src/lib/modules/guides/types.ts +++ b/apps/mana/apps/web/src/lib/modules/guides/types.ts @@ -66,6 +66,7 @@ export interface LocalRun extends BaseRecord { startedAt: string; completedAt: string | null; completedStepIds: string[]; + timeBlockId?: string | null; } // ─── Domain Types (UI-facing) ───────────────────────────── diff --git a/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts b/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts index 7261d73da..677b21a26 100644 --- a/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts @@ -5,7 +5,8 @@ * This store only exposes mutations that write to IndexedDB. */ -import { encryptRecord } from '$lib/data/crypto'; +import { encryptRecord, decryptRecord } from '$lib/data/crypto'; +import { createBlock } from '$lib/data/time-blocks/service'; import { placeTable } from '../collections'; import { toPlace } from '../queries'; import type { LocalPlace, Place, PlaceCategory } from '../types'; @@ -93,10 +94,25 @@ export const placesStore = { const local = await placeTable.get(id); if (!local) return; + const now = new Date().toISOString(); + const decrypted = await decryptRecord('places', { ...local }); + const placeName = decrypted?.name ?? 'Ort'; + await placeTable.update(id, { visitCount: (local.visitCount ?? 0) + 1, - lastVisitedAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + lastVisitedAt: now, + updatedAt: now, + }); + + await createBlock({ + startDate: now, + endDate: now, + kind: 'logged', + type: 'visit', + sourceModule: 'places', + sourceId: id, + title: placeName, + color: '#a855f7', }); }, }; diff --git a/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts b/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts index ac1f937ea..763af8d10 100644 --- a/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts @@ -6,6 +6,7 @@ */ import { decryptRecords, encryptRecord } from '$lib/data/crypto'; +import { createBlock } from '$lib/data/time-blocks/service'; import { locationLogTable, placeTable } from '../collections'; import { getDistanceKm, findNearestPlace, toPlace } from '../queries'; import type { LocalLocationLog, LocalPlace } from '../types'; @@ -134,7 +135,7 @@ async function logPosition(pos: GeolocationPosition) { await encryptRecord('locationLogs', log); await locationLogTable.add(log); - // Update visit count on the matched place + // Update visit count on the matched place + create TimeBlock if (nearest) { const local = await placeTable.get(nearest.id); if (local) { @@ -143,6 +144,17 @@ async function logPosition(pos: GeolocationPosition) { lastVisitedAt: log.timestamp, updatedAt: new Date().toISOString(), }); + + await createBlock({ + startDate: log.timestamp, + endDate: log.timestamp, + kind: 'logged', + type: 'visit', + sourceModule: 'places', + sourceId: nearest.id, + title: nearest.name, + color: '#a855f7', + }); } } }