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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-10 19:07:59 +02:00
parent 6ee1df390e
commit 29ad31c4ed
12 changed files with 154 additions and 12 deletions

View file

@ -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 {

View file

@ -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 {

View file

@ -30,6 +30,9 @@ const TYPE_COLORS: Record<TimeBlockType, string> = {
sleep: '#6366f1',
practice: '#f97316',
cycle: '#ec4899',
guide: '#14b8a6',
visit: '#a855f7',
study: '#0ea5e9',
};
const TYPE_LABELS: Record<TimeBlockType, string> = {
@ -44,6 +47,9 @@ const TYPE_LABELS: Record<TimeBlockType, string> = {
sleep: 'Schlaf',
practice: 'Übung',
cycle: 'Zyklus',
guide: 'Guides',
visit: 'Besuche',
study: 'Lernen',
};
function blockDuration(b: TimeBlock): number {

View file

@ -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) ──────────────────────────

View file

@ -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(

View file

@ -36,6 +36,9 @@ let visibleBlockTypes = $state<Set<TimeBlockType>>(
'sleep',
'practice',
'cycle',
'guide',
'visit',
'study',
])
);

View file

@ -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<string | null> {
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<void> {
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;
},

View file

@ -11,6 +11,7 @@ export interface LocalDeck extends BaseRecord {
cardCount: number;
lastStudied?: string | null;
isPublic: boolean;
activeStudyBlockId?: string | null;
}
export interface LocalCard extends BaseRecord {

View file

@ -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<Run> {
// 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<void> {
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<void> {
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 });
},
};

View file

@ -66,6 +66,7 @@ export interface LocalRun extends BaseRecord {
startedAt: string;
completedAt: string | null;
completedStepIds: string[];
timeBlockId?: string | null;
}
// ─── Domain Types (UI-facing) ─────────────────────────────

View file

@ -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',
});
},
};

View file

@ -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',
});
}
}
}