feat(timeblocks): integrate planta, dreams, skilltree, cycles modules

Extend the unified TimeBlock system to 5 additional modules so their
time-based activities appear in Calendar and Timeline views automatically.

New TimeBlockTypes: body, watering, sleep, practice, cycle
New SourceModules: body, planta, dreams, skilltree, cycles

- planta: logWatering() creates a 'watering' block with plant name
- dreams: createDream/updateDream creates 'sleep' block from bedtime→wakeTime
- skilltree: addXp() creates 'practice' block when duration is provided
- cycles: createCycle() creates allDay 'cycle' block, setPeriodEnd stamps endDate
- 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 18:54:04 +02:00
parent 3e812e8da7
commit 6ee1df390e
12 changed files with 246 additions and 24 deletions

View file

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

View file

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

View file

@ -25,6 +25,11 @@ const TYPE_COLORS: Record<TimeBlockType, string> = {
habit: '#22c55e',
focus: '#ef4444',
break: '#6b7280',
body: '#ef4444',
watering: '#06b6d4',
sleep: '#6366f1',
practice: '#f97316',
cycle: '#ec4899',
};
const TYPE_LABELS: Record<TimeBlockType, string> = {
@ -34,6 +39,11 @@ const TYPE_LABELS: Record<TimeBlockType, string> = {
habit: 'Habits',
focus: 'Fokus',
break: 'Pausen',
body: 'Training',
watering: 'Gießen',
sleep: 'Schlaf',
practice: 'Übung',
cycle: 'Zyklus',
};
function blockDuration(b: TimeBlock): number {

View file

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

View file

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

View file

@ -24,7 +24,19 @@ const SUPPORTED_VIEWS: CalendarViewType[] = ['week', 'month', 'agenda'];
let currentDate = $state(new Date());
let viewType = $state<CalendarViewType>('week');
let visibleBlockTypes = $state<Set<TimeBlockType>>(
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(() => {

View file

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

View file

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

View file

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

View file

@ -38,6 +38,7 @@ export interface LocalDream extends BaseRecord {
isPrivate: boolean;
isPinned: boolean;
isArchived: boolean;
timeBlockId?: string | null;
}
export interface LocalDreamSymbol extends BaseRecord {

View file

@ -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<void> {
const now = new Date().toISOString();
// Resolve plant name for TimeBlock title
const plant = await db.table<LocalPlant>('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<LocalWateringSchedule>('wateringSchedules').toArray();
const schedule = schedules.find((s) => s.plantId === plantId && !s.deletedAt);

View file

@ -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<LocalActivity>('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 };