From 105f99459e3ead896daaec7670b015c978ed00a1 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 5 Apr 2026 17:28:25 +0200 Subject: [PATCH] feat(timeblocks): analytics dashboard + iCal export Analytics: - analytics.ts: breakdownByType, breakdownByProject, dailyStats, habitHeatmap, planAdherence, productiveStreak - /timeline/analytics route: summary cards (total hours, streak, plan adherence, entries), type breakdown bars, daily chart, habit heatmap (90 days), plan vs reality stats - Period selector (7/14/30 days) iCal Export: - ical-export.ts: generateICalendar, downloadICalendar (RFC 5545) - VEVENT per TimeBlock with custom X-MANACORE properties - Export button in CalendarHeader (respects type filters) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/lib/data/time-blocks/analytics.ts | 217 ++++++++ .../src/lib/data/time-blocks/ical-export.ts | 109 ++++ .../web/src/lib/data/time-blocks/index.ts | 2 + .../calendar/components/CalendarHeader.svelte | 18 + .../(app)/timeline/analytics/+page.svelte | 476 ++++++++++++++++++ 5 files changed, 822 insertions(+) create mode 100644 apps/manacore/apps/web/src/lib/data/time-blocks/analytics.ts create mode 100644 apps/manacore/apps/web/src/lib/data/time-blocks/ical-export.ts create mode 100644 apps/manacore/apps/web/src/routes/(app)/timeline/analytics/+page.svelte diff --git a/apps/manacore/apps/web/src/lib/data/time-blocks/analytics.ts b/apps/manacore/apps/web/src/lib/data/time-blocks/analytics.ts new file mode 100644 index 000000000..2748eb9f4 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/data/time-blocks/analytics.ts @@ -0,0 +1,217 @@ +/** + * TimeBlock Analytics — Aggregation queries for time budget & insights. + * + * All functions are pure (operate on pre-fetched TimeBlock arrays). + * Fetch blocks once with useAllTimeBlocks() or a date-range query, + * then run analytics functions on the result. + */ + +import type { TimeBlock, TimeBlockType, TimeBlockSourceModule } from './types'; + +// ─── Time Breakdown ────────────────────────────────────── + +export interface TimeBreakdown { + label: string; + totalSeconds: number; + count: number; + percentage: number; + color: string; +} + +const TYPE_COLORS: Record = { + event: '#3b82f6', + task: '#f59e0b', + timeEntry: '#8b5cf6', + habit: '#22c55e', + focus: '#ef4444', + break: '#6b7280', +}; + +const TYPE_LABELS: Record = { + event: 'Termine', + task: 'Aufgaben', + timeEntry: 'Zeiterfassung', + habit: 'Habits', + focus: 'Fokus', + break: 'Pausen', +}; + +function blockDuration(b: TimeBlock): number { + if (!b.endDate) return 0; + return Math.max(0, (new Date(b.endDate).getTime() - new Date(b.startDate).getTime()) / 1000); +} + +/** Break down time by block type. */ +export function breakdownByType(blocks: TimeBlock[]): TimeBreakdown[] { + const groups = new Map(); + + for (const b of blocks) { + const dur = blockDuration(b); + const existing = groups.get(b.type) ?? { totalSeconds: 0, count: 0 }; + existing.totalSeconds += dur; + existing.count++; + groups.set(b.type, existing); + } + + const totalAll = [...groups.values()].reduce((sum, g) => sum + g.totalSeconds, 0); + + return [...groups.entries()] + .map(([type, data]) => ({ + label: TYPE_LABELS[type] ?? type, + totalSeconds: data.totalSeconds, + count: data.count, + percentage: totalAll > 0 ? (data.totalSeconds / totalAll) * 100 : 0, + color: TYPE_COLORS[type] ?? '#6b7280', + })) + .sort((a, b) => b.totalSeconds - a.totalSeconds); +} + +/** Break down time by project. */ +export function breakdownByProject(blocks: TimeBlock[]): TimeBreakdown[] { + const groups = new Map(); + + for (const b of blocks) { + const key = b.projectId || '__none__'; + const dur = blockDuration(b); + const existing = groups.get(key) ?? { totalSeconds: 0, count: 0, color: b.color || '#6b7280' }; + existing.totalSeconds += dur; + existing.count++; + groups.set(key, existing); + } + + const totalAll = [...groups.values()].reduce((sum, g) => sum + g.totalSeconds, 0); + + return [...groups.entries()] + .map(([key, data]) => ({ + label: key === '__none__' ? 'Kein Projekt' : key, + totalSeconds: data.totalSeconds, + count: data.count, + percentage: totalAll > 0 ? (data.totalSeconds / totalAll) * 100 : 0, + color: data.color, + })) + .sort((a, b) => b.totalSeconds - a.totalSeconds); +} + +// ─── Daily Stats ───────────────────────────────────────── + +export interface DailyStat { + date: string; // YYYY-MM-DD + totalSeconds: number; + byType: Record; +} + +/** Get daily time totals for a date range. */ +export function dailyStats(blocks: TimeBlock[], days: number = 7): DailyStat[] { + const today = new Date(); + const stats = new Map(); + + // Initialize days + for (let d = days - 1; d >= 0; d--) { + const date = new Date(today); + date.setDate(date.getDate() - d); + const dateStr = date.toISOString().split('T')[0]; + stats.set(dateStr, { date: dateStr, totalSeconds: 0, byType: {} }); + } + + for (const b of blocks) { + const dateStr = b.startDate.split('T')[0]; + const stat = stats.get(dateStr); + if (!stat) continue; + + const dur = blockDuration(b); + stat.totalSeconds += dur; + stat.byType[b.type] = (stat.byType[b.type] ?? 0) + dur; + } + + return [...stats.values()]; +} + +// ─── Habit Heatmap ─────────────────────────────────────── + +export interface HeatmapCell { + date: string; + count: number; + intensity: number; // 0-4 (like GitHub contribution graph) +} + +/** Generate a habit log heatmap for the last N days. */ +export function habitHeatmap(blocks: TimeBlock[], days: number = 90): HeatmapCell[] { + const habitBlocks = blocks.filter((b) => b.type === 'habit' && b.kind === 'logged'); + const countByDate = new Map(); + + for (const b of habitBlocks) { + const dateStr = b.startDate.split('T')[0]; + countByDate.set(dateStr, (countByDate.get(dateStr) ?? 0) + 1); + } + + const maxCount = Math.max(1, ...countByDate.values()); + const cells: HeatmapCell[] = []; + const today = new Date(); + + for (let d = days - 1; d >= 0; d--) { + const date = new Date(today); + date.setDate(date.getDate() - d); + const dateStr = date.toISOString().split('T')[0]; + const count = countByDate.get(dateStr) ?? 0; + const intensity = count === 0 ? 0 : Math.min(4, Math.ceil((count / maxCount) * 4)); + cells.push({ date: dateStr, count, intensity }); + } + + return cells; +} + +// ─── Plan vs Reality ───────────────────────────────────── + +export interface PlanAdherence { + totalScheduled: number; + totalCompleted: number; // has linkedBlockId + adherencePercent: number; + averageDelayMinutes: number; // how late did logged blocks start vs planned +} + +/** Calculate plan adherence stats. */ +export function planAdherence(blocks: TimeBlock[]): PlanAdherence { + const scheduled = blocks.filter((b) => b.kind === 'scheduled'); + const completed = scheduled.filter((b) => b.linkedBlockId); + + let totalDelay = 0; + let delayCount = 0; + + for (const s of completed) { + const logged = blocks.find((b) => b.id === s.linkedBlockId); + if (logged) { + const diff = (new Date(logged.startDate).getTime() - new Date(s.startDate).getTime()) / 60000; + totalDelay += Math.abs(diff); + delayCount++; + } + } + + return { + totalScheduled: scheduled.length, + totalCompleted: completed.length, + adherencePercent: + scheduled.length > 0 ? Math.round((completed.length / scheduled.length) * 100) : 0, + averageDelayMinutes: delayCount > 0 ? Math.round(totalDelay / delayCount) : 0, + }; +} + +// ─── Streaks ───────────────────────────────────────────── + +/** Calculate the current productive streak (consecutive days with at least one block). */ +export function productiveStreak(blocks: TimeBlock[]): number { + const dates = new Set(blocks.map((b) => b.startDate.split('T')[0])); + let streak = 0; + const d = new Date(); + + while (true) { + const dateStr = d.toISOString().split('T')[0]; + if (dates.has(dateStr)) { + streak++; + d.setDate(d.getDate() - 1); + } else { + break; + } + } + + return streak; +} diff --git a/apps/manacore/apps/web/src/lib/data/time-blocks/ical-export.ts b/apps/manacore/apps/web/src/lib/data/time-blocks/ical-export.ts new file mode 100644 index 000000000..c1e4fa007 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/data/time-blocks/ical-export.ts @@ -0,0 +1,109 @@ +/** + * iCal Export — Convert TimeBlocks to .ics (iCalendar) format. + * + * Generates valid VCALENDAR files with VEVENT entries. + * RFC 5545 compliant. + */ + +import type { TimeBlock } from './types'; + +/** Format a Date to iCal date-time string (YYYYMMDDTHHMMSSZ). */ +function formatICalDate(isoStr: string): string { + return isoStr.replace(/[-:]/g, '').replace(/\.\d{3}/, ''); +} + +/** Format a Date to iCal date-only string (YYYYMMDD). */ +function formatICalDateOnly(isoStr: string): string { + return isoStr.split('T')[0].replace(/-/g, ''); +} + +/** Escape special characters in iCal text fields. */ +function escapeICalText(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\n/g, '\\n'); +} + +/** Convert a single TimeBlock to a VEVENT string. */ +function timeBlockToVEvent(block: TimeBlock): string { + const lines: string[] = []; + lines.push('BEGIN:VEVENT'); + lines.push(`UID:${block.id}@manacore`); + lines.push(`DTSTAMP:${formatICalDate(new Date().toISOString())}`); + + if (block.allDay) { + lines.push(`DTSTART;VALUE=DATE:${formatICalDateOnly(block.startDate)}`); + if (block.endDate) { + lines.push(`DTEND;VALUE=DATE:${formatICalDateOnly(block.endDate)}`); + } + } else { + lines.push(`DTSTART:${formatICalDate(block.startDate)}`); + if (block.endDate) { + lines.push(`DTEND:${formatICalDate(block.endDate)}`); + } + } + + lines.push(`SUMMARY:${escapeICalText(block.title)}`); + + if (block.description) { + lines.push(`DESCRIPTION:${escapeICalText(block.description)}`); + } + + if (block.recurrenceRule) { + lines.push(`RRULE:${block.recurrenceRule}`); + } + + // Add custom properties for ManaCore metadata + lines.push(`X-MANACORE-TYPE:${block.type}`); + lines.push(`X-MANACORE-KIND:${block.kind}`); + lines.push(`X-MANACORE-MODULE:${block.sourceModule}`); + + if (block.color) { + lines.push(`COLOR:${block.color}`); + } + + lines.push(`CREATED:${formatICalDate(block.createdAt)}`); + lines.push(`LAST-MODIFIED:${formatICalDate(block.updatedAt)}`); + lines.push('END:VEVENT'); + + return lines.join('\r\n'); +} + +/** Generate a complete .ics file from TimeBlocks. */ +export function generateICalendar(blocks: TimeBlock[], calendarName: string = 'ManaCore'): string { + const lines: string[] = []; + lines.push('BEGIN:VCALENDAR'); + lines.push('VERSION:2.0'); + lines.push(`PRODID:-//ManaCore//TimeBlocks//EN`); + lines.push(`X-WR-CALNAME:${escapeICalText(calendarName)}`); + lines.push('CALSCALE:GREGORIAN'); + lines.push('METHOD:PUBLISH'); + + for (const block of blocks) { + lines.push(timeBlockToVEvent(block)); + } + + lines.push('END:VCALENDAR'); + return lines.join('\r\n'); +} + +/** Download a .ics file to the browser. */ +export function downloadICalendar( + blocks: TimeBlock[], + filename: string = 'manacore-export.ics', + calendarName?: string +): void { + const icsContent = generateICalendar(blocks, calendarName); + const blob = new Blob([icsContent], { type: 'text/calendar;charset=utf-8' }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} diff --git a/apps/manacore/apps/web/src/lib/data/time-blocks/index.ts b/apps/manacore/apps/web/src/lib/data/time-blocks/index.ts index dc57cf0b6..fe23f6b5f 100644 --- a/apps/manacore/apps/web/src/lib/data/time-blocks/index.ts +++ b/apps/manacore/apps/web/src/lib/data/time-blocks/index.ts @@ -2,3 +2,5 @@ export * from './types'; export * from './collections'; export * from './service'; export * from './queries'; +export * from './analytics'; +export { generateICalendar, downloadICalendar } from './ical-export'; diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/components/CalendarHeader.svelte b/apps/manacore/apps/web/src/lib/modules/calendar/components/CalendarHeader.svelte index 3ae1a2b01..48be26ca2 100644 --- a/apps/manacore/apps/web/src/lib/modules/calendar/components/CalendarHeader.svelte +++ b/apps/manacore/apps/web/src/lib/modules/calendar/components/CalendarHeader.svelte @@ -11,7 +11,12 @@ Timer, Heart, Funnel, + Export, } from '@manacore/shared-icons'; + import { db } from '$lib/data/database'; + import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; + import { toTimeBlock } from '$lib/data/time-blocks/queries'; + import { downloadICalendar } from '$lib/data/time-blocks/ical-export'; import { format } from 'date-fns'; import { de } from 'date-fns/locale'; @@ -34,6 +39,15 @@ blockTypeConfig.every((c) => calendarViewStore.visibleBlockTypes.has(c.type)) ); + async function handleExport() { + const locals = await db.table('timeBlocks').toArray(); + const blocks = locals + .filter((b) => !b.deletedAt) + .map(toTimeBlock) + .filter((b) => calendarViewStore.visibleBlockTypes.has(b.type)); + downloadICalendar(blocks); + } + let headerLabel = $derived.by(() => { if (calendarViewStore.viewType === 'month') { return format(calendarViewStore.currentDate, 'MMMM yyyy', { locale: de }); @@ -84,6 +98,10 @@ + + + {/each} + + + +
+ +
+
+ +
+ {totalHours}h + Gesamt +
+
+
+ +
+ {streak} + Tage Streak +
+
+
+ +
+ {adherence.adherencePercent}% + Plan-Treue +
+
+
+ +
+ {periodBlocks.length} + Einträge +
+
+
+ + +
+

Zeitverteilung

+ {#if typeBreakdown.length === 0} +

Keine Daten im Zeitraum

+ {:else} +
+ {#each typeBreakdown as item} +
+
+ + {item.label} + {formatHours(item.totalSeconds)} + {Math.round(item.percentage)}% +
+
+
+
+
+ {/each} +
+ {/if} +
+ + +
+

Tagesverteilung

+
+ {#each daily as day} + {@const barHeight = maxDailySeconds > 0 ? (day.totalSeconds / maxDailySeconds) * 100 : 0} +
+
+
+ {#if day.totalSeconds > 0} + {formatHours(day.totalSeconds)} + {/if} +
+
+ + {format(new Date(day.date), 'EEE', { locale: de })} + +
+ {/each} +
+
+ + +
+

Habit-Aktivität (90 Tage)

+
+ {#each heatmap as cell} +
+ {/each} +
+
+ + + {#if adherence.totalScheduled > 0} +
+

Plan vs Realität

+
+
+ {adherence.totalScheduled} + Geplant +
+
+ {adherence.totalCompleted} + Erledigt +
+
+ {adherence.adherencePercent}% + Treue +
+ {#if adherence.averageDelayMinutes > 0} +
+ {adherence.averageDelayMinutes}m + Ø Abweichung +
+ {/if} +
+
+ {/if} +
+ + +