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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-05 17:28:25 +02:00
parent 98dbcefe90
commit 105f99459e
5 changed files with 822 additions and 0 deletions

View file

@ -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<TimeBlockType, string> = {
event: '#3b82f6',
task: '#f59e0b',
timeEntry: '#8b5cf6',
habit: '#22c55e',
focus: '#ef4444',
break: '#6b7280',
};
const TYPE_LABELS: Record<TimeBlockType, string> = {
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<TimeBlockType, { totalSeconds: number; count: number }>();
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<string, { totalSeconds: number; count: number; color: string }>();
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<string, number>;
}
/** 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<string, DailyStat>();
// 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<string, number>();
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;
}

View file

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

View file

@ -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';

View file

@ -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<LocalTimeBlock>('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 @@
<Funnel size={16} />
</button>
<button class="filter-btn" onclick={handleExport} aria-label="Exportieren">
<Export size={16} />
</button>
<button onclick={onNewEvent} class="new-event-btn">
<Plus size={16} />
Termin

View file

@ -0,0 +1,476 @@
<!--
Analytics — Zeit-Budget Dashboard
Shows time breakdown, daily trends, habit heatmap, and plan adherence.
-->
<script lang="ts">
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { db } from '$lib/data/database';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import { toTimeBlock } from '$lib/data/time-blocks/queries';
import type { TimeBlock } from '$lib/data/time-blocks/types';
import {
breakdownByType,
dailyStats,
habitHeatmap,
planAdherence,
productiveStreak,
type TimeBreakdown,
type DailyStat,
type HeatmapCell,
} from '$lib/data/time-blocks/analytics';
import { Clock, TrendUp, Fire, Target } from '@manacore/shared-icons';
import { format, subDays } from 'date-fns';
import { de } from 'date-fns/locale';
let periodDays = $state(7);
const rangeStart = $derived(subDays(new Date(), periodDays).toISOString());
const rangeEnd = $derived(new Date().toISOString());
const blocksQuery = useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalTimeBlock>('timeBlocks').toArray();
return locals.filter((b) => !b.deletedAt).map(toTimeBlock);
}, [] as TimeBlock[]);
let allBlocks = $derived(blocksQuery.value ?? []);
let periodBlocks = $derived(
allBlocks.filter((b) => b.startDate >= rangeStart && b.startDate <= rangeEnd)
);
// Analytics
let typeBreakdown = $derived(breakdownByType(periodBlocks));
let daily = $derived(dailyStats(periodBlocks, periodDays));
let heatmap = $derived(habitHeatmap(allBlocks, 90));
let adherence = $derived(planAdherence(periodBlocks));
let streak = $derived(productiveStreak(allBlocks));
let totalHours = $derived(
Math.round((typeBreakdown.reduce((sum, t) => sum + t.totalSeconds, 0) / 3600) * 10) / 10
);
function formatHours(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.round((seconds % 3600) / 60);
if (h === 0) return `${m}m`;
if (m === 0) return `${h}h`;
return `${h}h ${m}m`;
}
const dayLabels = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
// Max daily seconds for bar scaling
let maxDailySeconds = $derived(Math.max(1, ...daily.map((d) => d.totalSeconds)));
</script>
<div class="analytics-page">
<header class="analytics-header">
<h1 class="header-title">Zeitanalyse</h1>
<div class="period-selector">
{#each [7, 14, 30] as days}
<button
class="period-btn"
class:active={periodDays === days}
onclick={() => (periodDays = days)}
>
{days}T
</button>
{/each}
</div>
</header>
<div class="analytics-grid">
<!-- Summary cards -->
<div class="summary-row">
<div class="stat-card">
<Clock size={20} class="stat-icon" />
<div class="stat-content">
<span class="stat-value">{totalHours}h</span>
<span class="stat-label">Gesamt</span>
</div>
</div>
<div class="stat-card">
<Fire size={20} class="stat-icon" />
<div class="stat-content">
<span class="stat-value">{streak}</span>
<span class="stat-label">Tage Streak</span>
</div>
</div>
<div class="stat-card">
<Target size={20} class="stat-icon" />
<div class="stat-content">
<span class="stat-value">{adherence.adherencePercent}%</span>
<span class="stat-label">Plan-Treue</span>
</div>
</div>
<div class="stat-card">
<TrendUp size={20} class="stat-icon" />
<div class="stat-content">
<span class="stat-value">{periodBlocks.length}</span>
<span class="stat-label">Einträge</span>
</div>
</div>
</div>
<!-- Type breakdown (donut-like horizontal bars) -->
<div class="card">
<h2 class="card-title">Zeitverteilung</h2>
{#if typeBreakdown.length === 0}
<p class="empty-text">Keine Daten im Zeitraum</p>
{:else}
<div class="breakdown-list">
{#each typeBreakdown as item}
<div class="breakdown-item">
<div class="breakdown-header">
<span class="breakdown-dot" style="background: {item.color}"></span>
<span class="breakdown-label">{item.label}</span>
<span class="breakdown-value">{formatHours(item.totalSeconds)}</span>
<span class="breakdown-percent">{Math.round(item.percentage)}%</span>
</div>
<div class="breakdown-bar-bg">
<div
class="breakdown-bar"
style="width: {item.percentage}%; background: {item.color}"
></div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Daily bars -->
<div class="card">
<h2 class="card-title">Tagesverteilung</h2>
<div class="daily-chart">
{#each daily as day}
{@const barHeight = maxDailySeconds > 0 ? (day.totalSeconds / maxDailySeconds) * 100 : 0}
<div class="daily-col">
<div class="daily-bar-container">
<div class="daily-bar" style="height: {barHeight}%">
{#if day.totalSeconds > 0}
<span class="daily-value">{formatHours(day.totalSeconds)}</span>
{/if}
</div>
</div>
<span class="daily-label">
{format(new Date(day.date), 'EEE', { locale: de })}
</span>
</div>
{/each}
</div>
</div>
<!-- Habit heatmap -->
<div class="card">
<h2 class="card-title">Habit-Aktivität (90 Tage)</h2>
<div class="heatmap">
{#each heatmap as cell}
<div
class="heatmap-cell"
class:intensity-0={cell.intensity === 0}
class:intensity-1={cell.intensity === 1}
class:intensity-2={cell.intensity === 2}
class:intensity-3={cell.intensity === 3}
class:intensity-4={cell.intensity === 4}
title="{cell.date}: {cell.count} Habits"
></div>
{/each}
</div>
</div>
<!-- Plan adherence -->
{#if adherence.totalScheduled > 0}
<div class="card">
<h2 class="card-title">Plan vs Realität</h2>
<div class="adherence-stats">
<div class="adherence-item">
<span class="adherence-value">{adherence.totalScheduled}</span>
<span class="adherence-label">Geplant</span>
</div>
<div class="adherence-item">
<span class="adherence-value">{adherence.totalCompleted}</span>
<span class="adherence-label">Erledigt</span>
</div>
<div class="adherence-item">
<span class="adherence-value">{adherence.adherencePercent}%</span>
<span class="adherence-label">Treue</span>
</div>
{#if adherence.averageDelayMinutes > 0}
<div class="adherence-item">
<span class="adherence-value">{adherence.averageDelayMinutes}m</span>
<span class="adherence-label">Ø Abweichung</span>
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
<style>
.analytics-page {
display: flex;
flex-direction: column;
height: 100%;
background: hsl(var(--color-background));
overflow-y: auto;
}
.analytics-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.header-title {
font-size: 1.25rem;
font-weight: 700;
margin: 0;
}
.period-selector {
display: flex;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-md, 8px);
overflow: hidden;
}
.period-btn {
padding: 0.375rem 0.75rem;
border: none;
background: transparent;
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
}
.period-btn:hover {
color: hsl(var(--color-foreground));
}
.period-btn.active {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.analytics-grid {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* Summary cards */
.summary-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
}
@media (max-width: 640px) {
.summary-row {
grid-template-columns: repeat(2, 1fr);
}
}
.stat-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: var(--radius-lg, 12px);
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
}
.stat-content {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 1.25rem;
font-weight: 700;
color: hsl(var(--color-foreground));
}
.stat-label {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
/* Cards */
.card {
padding: 1.25rem;
border-radius: var(--radius-lg, 12px);
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
}
.card-title {
font-size: 0.9375rem;
font-weight: 600;
margin: 0 0 1rem;
color: hsl(var(--color-foreground));
}
.empty-text {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
padding: 1rem 0;
}
/* Type breakdown */
.breakdown-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.breakdown-header {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.breakdown-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.breakdown-label {
color: hsl(var(--color-foreground));
font-weight: 500;
}
.breakdown-value {
margin-left: auto;
color: hsl(var(--color-foreground));
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.breakdown-percent {
width: 2.5rem;
text-align: right;
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
}
.breakdown-bar-bg {
height: 6px;
border-radius: 3px;
background: hsl(var(--color-muted));
overflow: hidden;
}
.breakdown-bar {
height: 100%;
border-radius: 3px;
transition: width 0.5s ease;
}
/* Daily chart */
.daily-chart {
display: flex;
align-items: flex-end;
gap: 0.375rem;
height: 140px;
}
.daily-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
height: 100%;
}
.daily-bar-container {
flex: 1;
width: 100%;
display: flex;
align-items: flex-end;
}
.daily-bar {
width: 100%;
background: hsl(var(--color-primary));
border-radius: 4px 4px 0 0;
min-height: 2px;
transition: height 0.5s ease;
display: flex;
align-items: flex-start;
justify-content: center;
}
.daily-value {
font-size: 0.5625rem;
color: hsl(var(--color-primary-foreground));
padding-top: 2px;
white-space: nowrap;
}
.daily-label {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
/* Heatmap */
.heatmap {
display: flex;
flex-wrap: wrap;
gap: 2px;
}
.heatmap-cell {
width: 10px;
height: 10px;
border-radius: 2px;
}
.heatmap-cell.intensity-0 {
background: hsl(var(--color-muted));
}
.heatmap-cell.intensity-1 {
background: hsl(142 71% 45% / 0.25);
}
.heatmap-cell.intensity-2 {
background: hsl(142 71% 45% / 0.5);
}
.heatmap-cell.intensity-3 {
background: hsl(142 71% 45% / 0.75);
}
.heatmap-cell.intensity-4 {
background: hsl(142 71% 45%);
}
/* Adherence */
.adherence-stats {
display: flex;
gap: 2rem;
}
.adherence-item {
display: flex;
flex-direction: column;
align-items: center;
}
.adherence-value {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
}
.adherence-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
</style>