mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
3e812e8da7
commit
6ee1df390e
12 changed files with 246 additions and 24 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) ──────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export interface LocalDream extends BaseRecord {
|
|||
isPrivate: boolean;
|
||||
isPinned: boolean;
|
||||
isArchived: boolean;
|
||||
timeBlockId?: string | null;
|
||||
}
|
||||
|
||||
export interface LocalDreamSymbol extends BaseRecord {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue