mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
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:
parent
98dbcefe90
commit
105f99459e
5 changed files with 822 additions and 0 deletions
217
apps/manacore/apps/web/src/lib/data/time-blocks/analytics.ts
Normal file
217
apps/manacore/apps/web/src/lib/data/time-blocks/analytics.ts
Normal 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;
|
||||
}
|
||||
109
apps/manacore/apps/web/src/lib/data/time-blocks/ical-export.ts
Normal file
109
apps/manacore/apps/web/src/lib/data/time-blocks/ical-export.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue