mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(dashboard): add "Mein Tag" timeline widget using timeBlocks
Chronological day timeline showing all timeBlocks for today across all modules (events, tasks, habits, time entries). Shows summary stats (total time, counts per type), live indicators for running timers, and habit icons. Links to calendar for full view. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2502d6241d
commit
ee7ff7d5e8
3 changed files with 197 additions and 9 deletions
|
|
@ -21,7 +21,7 @@ import PictureRecentWidget from './widgets/PictureRecentWidget.svelte';
|
|||
import CardsProgressWidget from './widgets/CardsProgressWidget.svelte';
|
||||
import ClockTimersWidget from './widgets/ClockTimersWidget.svelte';
|
||||
import StorageUsageWidget from './widgets/StorageUsageWidget.svelte';
|
||||
import MukkeLibraryWidget from './widgets/MukkeLibraryWidget.svelte';
|
||||
import MusicLibraryWidget from './widgets/MusicLibraryWidget.svelte';
|
||||
import PresiDecksWidget from './widgets/PresiDecksWidget.svelte';
|
||||
import ContextDocsWidget from './widgets/ContextDocsWidget.svelte';
|
||||
|
||||
|
|
@ -30,6 +30,7 @@ import RecentContactsWidget from '$lib/modules/core/widgets/RecentContactsWidget
|
|||
import ActiveTimerWidget from '$lib/modules/core/widgets/ActiveTimerWidget.svelte';
|
||||
import NutritionProgressWidget from '$lib/modules/core/widgets/NutritionProgressWidget.svelte';
|
||||
import PlantWateringWidget from '$lib/modules/core/widgets/PlantWateringWidget.svelte';
|
||||
import DayTimelineWidget from './widgets/DayTimelineWidget.svelte';
|
||||
|
||||
export const widgetComponents: Record<WidgetType, Component> = {
|
||||
credits: CreditsWidget,
|
||||
|
|
@ -46,10 +47,11 @@ export const widgetComponents: Record<WidgetType, Component> = {
|
|||
'cards-progress': CardsProgressWidget,
|
||||
'clock-timers': ClockTimersWidget,
|
||||
'storage-usage': StorageUsageWidget,
|
||||
'mukke-library': MukkeLibraryWidget,
|
||||
'music-library': MusicLibraryWidget,
|
||||
'presi-decks': PresiDecksWidget,
|
||||
'context-docs': ContextDocsWidget,
|
||||
'active-timer': ActiveTimerWidget,
|
||||
'nutrition-progress': NutritionProgressWidget,
|
||||
'plant-watering': PlantWateringWidget,
|
||||
'day-timeline': DayTimelineWidget,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* DayTimelineWidget — "Mein Tag" chronological timeline
|
||||
*
|
||||
* Shows all timeBlocks for today across all modules (events, tasks, habits, time entries).
|
||||
* The key showcase of the Unified Time Model.
|
||||
*/
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
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 '@manacore/shared-icons';
|
||||
import { getIconComponent } from '@manacore/shared-icons';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
const todayStart = `${todayStr}T00:00:00.000Z`;
|
||||
const todayEnd = `${todayStr}T23:59:59.999Z`;
|
||||
|
||||
const blocksQuery = useLiveQueryWithDefault(async () => {
|
||||
const locals = await db
|
||||
.table<LocalTimeBlock>('timeBlocks')
|
||||
.where('startDate')
|
||||
.between(todayStart, todayEnd, true, true)
|
||||
.toArray();
|
||||
return locals
|
||||
.filter((b) => !b.deletedAt)
|
||||
.map(toTimeBlock)
|
||||
.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||
}, [] as TimeBlock[]);
|
||||
|
||||
let blocks = $derived(blocksQuery.value ?? []);
|
||||
|
||||
// Summary stats
|
||||
let totalMinutes = $derived(blocks.reduce((sum, b) => sum + getBlockDuration(b) / 60, 0));
|
||||
let typeCounts = $derived(() => {
|
||||
const counts = new Map<TimeBlockType, number>();
|
||||
for (const b of blocks) {
|
||||
counts.set(b.type, (counts.get(b.type) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
const MAX_DISPLAY = 8;
|
||||
let displayedBlocks = $derived(blocks.slice(0, MAX_DISPLAY));
|
||||
let remainingCount = $derived(Math.max(0, blocks.length - MAX_DISPLAY));
|
||||
|
||||
const typeConfig: Record<string, { icon: typeof CalendarBlank; label: string }> = {
|
||||
event: { icon: CalendarBlank, label: 'Termin' },
|
||||
task: { icon: CheckSquare, label: 'Aufgabe' },
|
||||
timeEntry: { icon: Timer, label: 'Zeiterfassung' },
|
||||
habit: { icon: Heart, label: 'Habit' },
|
||||
focus: { icon: Lightning, label: 'Fokus' },
|
||||
break: { icon: Clock, label: 'Pause' },
|
||||
};
|
||||
|
||||
function formatBlockTime(block: TimeBlock): string {
|
||||
const start = format(new Date(block.startDate), 'HH:mm');
|
||||
if (block.isLive) return `${start} — jetzt`;
|
||||
if (!block.endDate) return start;
|
||||
const end = format(new Date(block.endDate), 'HH:mm');
|
||||
return `${start} — ${end}`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h === 0) return `${m}m`;
|
||||
if (m === 0) return `${h}h`;
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-lg font-semibold">
|
||||
<span><Clock size={20} /></span>
|
||||
{$_('dashboard.widgets.day_timeline.title', { default: 'Mein Tag' })}
|
||||
</h3>
|
||||
{#if blocks.length > 0}
|
||||
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-sm font-medium text-primary">
|
||||
{blocks.length}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if blocksQuery.loading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(4) as _}
|
||||
<div class="h-10 animate-pulse rounded bg-surface-hover"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if blocks.length === 0}
|
||||
<div class="py-6 text-center">
|
||||
<div class="mb-2 text-3xl"><Clock size={32} /></div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{$_('dashboard.widgets.day_timeline.empty', { default: 'Noch nichts heute' })}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Summary bar -->
|
||||
{#if totalMinutes > 0}
|
||||
<div class="mb-3 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{formatDuration(totalMinutes * 60)} erfasst</span>
|
||||
{#each [...typeCounts().entries()] as [type, count]}
|
||||
{@const cfg = typeConfig[type]}
|
||||
{#if cfg}
|
||||
<span class="flex items-center gap-1">
|
||||
<svelte:component this={cfg.icon} size={12} />
|
||||
{count}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Timeline -->
|
||||
<div class="space-y-1">
|
||||
{#each displayedBlocks as block (block.id)}
|
||||
{@const cfg = typeConfig[block.type] ?? typeConfig.event}
|
||||
{@const habitIcon =
|
||||
block.type === 'habit' && block.icon ? getIconComponent(block.icon) : null}
|
||||
{@const duration = getBlockDuration(block)}
|
||||
<div
|
||||
class="flex items-start gap-2.5 rounded-lg p-2 transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<!-- Color dot + icon -->
|
||||
<div class="mt-0.5 flex flex-shrink-0 items-center gap-1.5">
|
||||
<div
|
||||
class="h-2.5 w-2.5 rounded-full"
|
||||
class:animate-pulse={block.isLive}
|
||||
style="background-color: {block.color || '#6b7280'}"
|
||||
></div>
|
||||
{#if habitIcon}
|
||||
<svelte:component this={habitIcon} size={14} class="text-muted-foreground" />
|
||||
{:else}
|
||||
<svelte:component this={cfg.icon} size={14} class="text-muted-foreground" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">{block.title}</p>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{formatBlockTime(block)}</span>
|
||||
{#if duration > 0}
|
||||
<span>{formatDuration(duration)}</span>
|
||||
{/if}
|
||||
{#if block.isLive}
|
||||
<span class="rounded bg-green-500/20 px-1 text-green-600">live</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if remainingCount > 0}
|
||||
<a
|
||||
href="/calendar"
|
||||
class="block rounded-lg py-2 text-center text-sm text-primary hover:bg-primary/5"
|
||||
>
|
||||
+{remainingCount} weitere
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -22,12 +22,13 @@ export type WidgetType =
|
|||
| 'cards-progress' // Cards API: learning progress
|
||||
| 'clock-timers' // Clock: active timers and alarms
|
||||
| 'storage-usage' // Storage: file storage stats
|
||||
| 'mukke-library' // Mukke: music library stats
|
||||
| 'music-library' // Music: music library stats
|
||||
| 'presi-decks' // Presi: recent presentations
|
||||
| 'context-docs' // Context: recent documents & spaces
|
||||
| 'active-timer' // Times: running timer
|
||||
| 'nutrition-progress' // NutriPhi: today's calorie progress
|
||||
| 'plant-watering'; // Planta: plants due for watering
|
||||
| 'plant-watering' // Planta: plants due for watering
|
||||
| 'day-timeline'; // TimeBlocks: chronological day timeline
|
||||
|
||||
/**
|
||||
* Widget size - maps to CSS Grid columns
|
||||
|
|
@ -121,7 +122,7 @@ export interface WidgetMeta {
|
|||
| 'picture'
|
||||
| 'cards'
|
||||
| 'storage'
|
||||
| 'mukke'
|
||||
| 'music'
|
||||
| 'presi'
|
||||
| 'context'
|
||||
| 'times'
|
||||
|
|
@ -251,13 +252,13 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
|
|||
requiredBackend: 'storage',
|
||||
},
|
||||
{
|
||||
type: 'mukke-library',
|
||||
nameKey: 'dashboard.widgets.mukke.title',
|
||||
descriptionKey: 'dashboard.widgets.mukke.description',
|
||||
type: 'music-library',
|
||||
nameKey: 'dashboard.widgets.music.title',
|
||||
descriptionKey: 'dashboard.widgets.music.description',
|
||||
icon: '🎵',
|
||||
defaultSize: 'medium',
|
||||
allowMultiple: false,
|
||||
requiredBackend: 'mukke',
|
||||
requiredBackend: 'music',
|
||||
},
|
||||
{
|
||||
type: 'presi-decks',
|
||||
|
|
@ -313,6 +314,14 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
|
|||
allowMultiple: false,
|
||||
requiredBackend: 'planta',
|
||||
},
|
||||
{
|
||||
type: 'day-timeline',
|
||||
nameKey: 'dashboard.widgets.day_timeline.title',
|
||||
descriptionKey: 'dashboard.widgets.day_timeline.description',
|
||||
icon: '⏱️',
|
||||
defaultSize: 'medium',
|
||||
allowMultiple: false,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue