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 5d51be94a..d29458af0 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 @@ -21,6 +21,11 @@ Lightning, Clock, Pulse, + Barbell, + Drop, + Moon, + GraduationCap, + FlowerLotus, } from '@mana/shared-icons'; import { getIconComponent } from '@mana/shared-icons'; import { formatDistanceToNow } from 'date-fns'; @@ -47,6 +52,11 @@ habit: Heart, focus: Lightning, break: Clock, + body: Barbell, + watering: Drop, + sleep: Moon, + practice: GraduationCap, + cycle: FlowerLotus, }; 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 f5965ce63..5983f4d5a 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 @@ -13,7 +13,19 @@ import type { LocalTimeBlock, TimeBlockType } from '$lib/data/time-blocks/types'; import { toTimeBlock, getBlockDuration } from '$lib/data/time-blocks/queries'; import type { TimeBlock } from '$lib/data/time-blocks/types'; - import { CalendarBlank, CheckSquare, Timer, Heart, Lightning, Clock } from '@mana/shared-icons'; + import { + CalendarBlank, + CheckSquare, + Timer, + Heart, + Lightning, + Clock, + Barbell, + Drop, + Moon, + GraduationCap, + FlowerLotus, + } from '@mana/shared-icons'; import { getIconComponent } from '@mana/shared-icons'; import { format } from 'date-fns'; @@ -55,6 +67,11 @@ habit: { icon: Heart, label: 'Habit' }, focus: { icon: Lightning, label: 'Fokus' }, break: { icon: Clock, label: 'Pause' }, + body: { icon: Barbell, label: 'Training' }, + watering: { icon: Drop, label: 'Gießen' }, + sleep: { icon: Moon, label: 'Schlaf' }, + practice: { icon: GraduationCap, label: 'Übung' }, + cycle: { icon: FlowerLotus, label: 'Zyklus' }, }; 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 2748eb9f4..f8dd95a7f 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 @@ -25,6 +25,11 @@ const TYPE_COLORS: Record = { habit: '#22c55e', focus: '#ef4444', break: '#6b7280', + body: '#ef4444', + watering: '#06b6d4', + sleep: '#6366f1', + practice: '#f97316', + cycle: '#ec4899', }; const TYPE_LABELS: Record = { @@ -34,6 +39,11 @@ const TYPE_LABELS: Record = { habit: 'Habits', focus: 'Fokus', break: 'Pausen', + body: 'Training', + watering: 'Gießen', + sleep: 'Schlaf', + practice: 'Übung', + cycle: 'Zyklus', }; 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 bf756d6c4..b9237f331 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 @@ -12,9 +12,30 @@ import type { BaseRecord } from '@mana/local-store'; export type TimeBlockKind = 'scheduled' | 'logged'; -export type TimeBlockType = 'event' | 'task' | 'habit' | 'timeEntry' | 'focus' | 'break'; +export type TimeBlockType = + | 'event' + | 'task' + | 'habit' + | 'timeEntry' + | 'focus' + | 'break' + | 'body' + | 'watering' + | 'sleep' + | 'practice' + | 'cycle'; -export type TimeBlockSourceModule = 'calendar' | 'todo' | 'times' | 'habits' | 'events'; +export type TimeBlockSourceModule = + | 'calendar' + | 'todo' + | 'times' + | 'habits' + | 'events' + | 'body' + | 'planta' + | 'dreams' + | 'skilltree' + | 'cycles'; // ─── 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 66700689d..629c66297 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 @@ -12,6 +12,11 @@ Heart, Funnel, Export, + Barbell, + Drop, + Moon, + GraduationCap, + FlowerLotus, } from '@mana/shared-icons'; import { db } from '$lib/data/database'; import { decryptRecords } from '$lib/data/crypto'; @@ -34,6 +39,11 @@ { type: 'task', label: 'Aufgaben', icon: CheckSquare }, { type: 'timeEntry', label: 'Zeiten', icon: Timer }, { type: 'habit', label: 'Habits', icon: Heart }, + { type: 'body', label: 'Training', icon: Barbell }, + { type: 'watering', label: 'Gießen', icon: Drop }, + { type: 'sleep', label: 'Schlaf', icon: Moon }, + { type: 'practice', label: 'Übung', icon: GraduationCap }, + { type: 'cycle', label: 'Zyklus', icon: FlowerLotus }, ]; 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 151ad6ae3..c5ac68752 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 @@ -24,7 +24,19 @@ const SUPPORTED_VIEWS: CalendarViewType[] = ['week', 'month', 'agenda']; let currentDate = $state(new Date()); let viewType = $state('week'); let visibleBlockTypes = $state>( - new Set(['event', 'task', 'habit', 'timeEntry', 'focus', 'break']) + new Set([ + 'event', + 'task', + 'habit', + 'timeEntry', + 'focus', + 'break', + 'body', + 'watering', + 'sleep', + 'practice', + 'cycle', + ]) ); const viewRange = $derived.by(() => { diff --git a/apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.svelte.ts b/apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.svelte.ts index e25da80f3..dc844daf5 100644 --- a/apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.svelte.ts @@ -6,6 +6,7 @@ import { cycleTable } from '../collections'; import { toCycle } from '../queries'; import { daysBetween } from '../utils/phase'; import { encryptRecord } from '$lib/data/crypto'; +import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service'; import type { LocalCycle } from '../types'; function todayIsoDate(): string { @@ -39,8 +40,22 @@ export const cyclesStore = { }); } + // Create a TimeBlock for the menstruation phase (allDay, open-ended until periodEnd is set) + const cycleId = crypto.randomUUID(); + const timeBlockId = await createBlock({ + startDate: `${startDate}T00:00:00.000Z`, + endDate: null, + allDay: true, + kind: 'logged', + type: 'cycle', + sourceModule: 'cycles', + sourceId: cycleId, + title: 'Periode', + color: '#ec4899', + }); + const newLocal: LocalCycle = { - id: crypto.randomUUID(), + id: cycleId, startDate, periodEndDate: null, endDate: null, @@ -48,6 +63,7 @@ export const cyclesStore = { isPredicted: false, isArchived: false, notes: data.notes ?? null, + timeBlockId, }; const plaintextSnapshot = toCycle(newLocal); await encryptRecord('cycles', newLocal); @@ -74,13 +90,24 @@ export const cyclesStore = { /** Markiert das Ende der Blutung (nicht das Ende des Zyklus). */ async setPeriodEnd(id: string, periodEndDate: string | null) { + const cycle = await cycleTable.get(id); await cycleTable.update(id, { periodEndDate, updatedAt: new Date().toISOString(), }); + // Update the TimeBlock's endDate to reflect the period duration + if (cycle?.timeBlockId && periodEndDate) { + await updateBlock(cycle.timeBlockId, { + endDate: `${periodEndDate}T23:59:59.999Z`, + }); + } }, async deleteCycle(id: string) { + const cycle = await cycleTable.get(id); + if (cycle?.timeBlockId) { + await deleteBlock(cycle.timeBlockId); + } await cycleTable.update(id, { deletedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), diff --git a/apps/mana/apps/web/src/lib/modules/cycles/types.ts b/apps/mana/apps/web/src/lib/modules/cycles/types.ts index 4d354e6f8..df50c1369 100644 --- a/apps/mana/apps/web/src/lib/modules/cycles/types.ts +++ b/apps/mana/apps/web/src/lib/modules/cycles/types.ts @@ -20,6 +20,7 @@ export interface LocalCycle extends BaseRecord { isPredicted: boolean; isArchived: boolean; notes: string | null; + timeBlockId?: string | null; // link to timeBlocks table (menstruation phase) } export interface LocalCycleDayLog extends BaseRecord { diff --git a/apps/mana/apps/web/src/lib/modules/dreams/stores/dreams.svelte.ts b/apps/mana/apps/web/src/lib/modules/dreams/stores/dreams.svelte.ts index 8a5efc690..e67c7605a 100644 --- a/apps/mana/apps/web/src/lib/modules/dreams/stores/dreams.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/dreams/stores/dreams.svelte.ts @@ -11,6 +11,7 @@ import { dreamSymbolTable, dreamTable } from '../collections'; import { toDream } from '../queries'; import { encryptRecord } from '$lib/data/crypto'; +import { createBlock, deleteBlock } from '$lib/data/time-blocks/service'; import { transcribeAudio } from '$lib/voice/transcribe'; import type { Dream, @@ -25,6 +26,32 @@ function todayIsoDate(): string { return new Date().toISOString().slice(0, 10); } +/** + * Build ISO start/end timestamps for a sleep session. + * bedtime is assumed to be the evening before dreamDate, + * wakeTime is the morning of dreamDate. + */ +function buildSleepRange( + dreamDate: string, + bedtime: string, + wakeTime: string +): { startDate: string; endDate: string } { + const bedHour = parseInt(bedtime.split(':')[0], 10); + // If bedtime is before 12:00, assume same day; otherwise previous day + const bedDay = + bedHour < 12 + ? dreamDate + : (() => { + const d = new Date(dreamDate); + d.setDate(d.getDate() - 1); + return d.toISOString().slice(0, 10); + })(); + return { + startDate: `${bedDay}T${bedtime}:00.000Z`, + endDate: `${dreamDate}T${wakeTime}:00.000Z`, + }; +} + export const dreamsStore = { async createDream(data: { title?: string | null; @@ -35,19 +62,40 @@ export const dreamsStore = { isLucid?: boolean; symbols?: string[]; emotions?: string[]; + bedtime?: string | null; + wakeTime?: string | null; }) { + const dreamDate = data.dreamDate ?? todayIsoDate(); + const dreamId = crypto.randomUUID(); + + // Create sleep TimeBlock if both bedtime and wakeTime are provided + let timeBlockId: string | null = null; + if (data.bedtime && data.wakeTime) { + const range = buildSleepRange(dreamDate, data.bedtime, data.wakeTime); + timeBlockId = await createBlock({ + startDate: range.startDate, + endDate: range.endDate, + kind: 'logged', + type: 'sleep', + sourceModule: 'dreams', + sourceId: dreamId, + title: data.title ?? 'Schlaf', + color: '#6366f1', + }); + } + const newLocal: LocalDream = { - id: crypto.randomUUID(), + id: dreamId, title: data.title ?? null, content: data.content ?? '', - dreamDate: data.dreamDate ?? todayIsoDate(), + dreamDate, mood: data.mood ?? null, clarity: data.clarity ?? null, isLucid: data.isLucid ?? false, isRecurring: false, sleepQuality: null, - bedtime: null, - wakeTime: null, + bedtime: data.bedtime ?? null, + wakeTime: data.wakeTime ?? null, location: null, people: [], emotions: data.emotions ?? [], @@ -63,15 +111,12 @@ export const dreamsStore = { isPrivate: false, isPinned: false, isArchived: false, + timeBlockId, }; const plaintextSnapshot = toDream(newLocal); await encryptRecord('dreams', newLocal); await dreamTable.add(newLocal); - // touchSymbols receives plaintext names — must run BEFORE the - // snapshot mutation above doesn't matter because newLocal.symbols - // is a non-encrypted field, but use the snapshot's symbols just - // to be explicit about what we're feeding the symbol counter. await this.touchSymbols(plaintextSnapshot.symbols, +1); return plaintextSnapshot; }, @@ -103,15 +148,44 @@ export const dreamsStore = { > > ) { - if (data.symbols) { - const existing = await dreamTable.get(id); - if (existing) { - const oldSet = new Set(existing.symbols ?? []); - const newSet = new Set(data.symbols); - const added = [...newSet].filter((s) => !oldSet.has(s)); - const removed = [...oldSet].filter((s) => !newSet.has(s)); - if (added.length) await this.touchSymbols(added, +1); - if (removed.length) await this.touchSymbols(removed, -1); + const existing = await dreamTable.get(id); + + if (data.symbols && existing) { + const oldSet = new Set(existing.symbols ?? []); + const newSet = new Set(data.symbols); + const added = [...newSet].filter((s) => !oldSet.has(s)); + const removed = [...oldSet].filter((s) => !newSet.has(s)); + if (added.length) await this.touchSymbols(added, +1); + if (removed.length) await this.touchSymbols(removed, -1); + } + + // Create or update sleep TimeBlock when bedtime/wakeTime change + if (existing && (data.bedtime !== undefined || data.wakeTime !== undefined)) { + const bedtime = data.bedtime ?? existing.bedtime; + const wakeTime = data.wakeTime ?? existing.wakeTime; + const dreamDate = data.dreamDate ?? existing.dreamDate; + + if (bedtime && wakeTime) { + const range = buildSleepRange(dreamDate, bedtime, wakeTime); + if (!existing.timeBlockId) { + const timeBlockId = await createBlock({ + startDate: range.startDate, + endDate: range.endDate, + kind: 'logged', + type: 'sleep', + sourceModule: 'dreams', + sourceId: id, + title: data.title ?? existing.title ?? 'Schlaf', + color: '#6366f1', + }); + data = { ...data, timeBlockId } as typeof data; + } else { + const { updateBlock } = await import('$lib/data/time-blocks/service'); + await updateBlock(existing.timeBlockId, { + startDate: range.startDate, + endDate: range.endDate, + }); + } } } @@ -223,6 +297,9 @@ export const dreamsStore = { if (existing?.symbols?.length) { await this.touchSymbols(existing.symbols, -1); } + if (existing?.timeBlockId) { + await deleteBlock(existing.timeBlockId); + } await dreamTable.update(id, { deletedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), diff --git a/apps/mana/apps/web/src/lib/modules/dreams/types.ts b/apps/mana/apps/web/src/lib/modules/dreams/types.ts index a8ee6e7d2..b21e5d4be 100644 --- a/apps/mana/apps/web/src/lib/modules/dreams/types.ts +++ b/apps/mana/apps/web/src/lib/modules/dreams/types.ts @@ -38,6 +38,7 @@ export interface LocalDream extends BaseRecord { isPrivate: boolean; isPinned: boolean; isArchived: boolean; + timeBlockId?: string | null; } export interface LocalDreamSymbol extends BaseRecord { diff --git a/apps/mana/apps/web/src/lib/modules/planta/mutations.ts b/apps/mana/apps/web/src/lib/modules/planta/mutations.ts index afcc5c7a5..2c7ff6afd 100644 --- a/apps/mana/apps/web/src/lib/modules/planta/mutations.ts +++ b/apps/mana/apps/web/src/lib/modules/planta/mutations.ts @@ -9,6 +9,7 @@ import { db } from '$lib/data/database'; import { toPlant, toWateringSchedule } from './queries'; import { PlantaEvents } from '@mana/shared-utils/analytics'; import { encryptRecord, decryptRecord } from '$lib/data/crypto'; +import { createBlock } from '$lib/data/time-blocks/service'; import { uploadPlantPhoto, identifyPlant, type IdentifyResult } from './api'; import type { LocalPlant, @@ -116,6 +117,11 @@ export const wateringMutations = { async logWatering(plantId: string, notes?: string): Promise { const now = new Date().toISOString(); + // Resolve plant name for TimeBlock title + const plant = await db.table('plants').get(plantId); + const decryptedPlant = plant ? await decryptRecord('plants', { ...plant }) : null; + const plantName = decryptedPlant?.name ?? 'Pflanze'; + // Create watering log entry const logEntry: LocalWateringLog = { id: crypto.randomUUID(), @@ -127,6 +133,18 @@ export const wateringMutations = { }; await db.table('wateringLogs').add(logEntry); + // Create a TimeBlock for the watering event + await createBlock({ + startDate: now, + endDate: now, + kind: 'logged', + type: 'watering', + sourceModule: 'planta', + sourceId: logEntry.id, + title: `${plantName} gegossen`, + color: '#06b6d4', + }); + // Update watering schedule const schedules = await db.table('wateringSchedules').toArray(); const schedule = schedules.find((s) => s.plantId === plantId && !s.deletedAt); diff --git a/apps/mana/apps/web/src/lib/modules/skilltree/stores/skills.svelte.ts b/apps/mana/apps/web/src/lib/modules/skilltree/stores/skills.svelte.ts index 3a5f87d7b..48af8524a 100644 --- a/apps/mana/apps/web/src/lib/modules/skilltree/stores/skills.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/skilltree/stores/skills.svelte.ts @@ -10,6 +10,7 @@ import type { Skill } from '../types'; import { calculateLevel, createDefaultSkill, createActivity } from '../types'; import type { LocalSkill, LocalActivity } from '../types'; import { SkillTreeEvents } from '@mana/shared-utils/analytics'; +import { createBlock } from '$lib/data/time-blocks/service'; // ─── Actions ───────────────────────────────────────────────── @@ -87,6 +88,7 @@ async function addXp( }); const activity = createActivity(skillId, xp, description, duration); + const now = new Date().toISOString(); await db.table('activities').add({ id: activity.id, skillId: activity.skillId, @@ -94,10 +96,26 @@ async function addXp( description: activity.description, duration: activity.duration, timestamp: activity.timestamp, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: now, + updatedAt: now, }); + // Create a TimeBlock for practice sessions with duration + if (duration && duration > 0) { + const startDate = new Date(activity.timestamp); + const endDate = new Date(startDate.getTime() + duration * 60_000); + await createBlock({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + kind: 'logged', + type: 'practice', + sourceModule: 'skilltree', + sourceId: activity.id, + title: `${skill.name}: ${description}`, + color: skill.color ?? '#f97316', + }); + } + SkillTreeEvents.xpAdded(xp, leveledUp); return { leveledUp, newLevel };