feat(manacore): Phase 4 — cross-app dashboard widgets

10 new widgets using direct Dexie queries on the unified database:

- TasksTodayWidget: overdue + today's tasks with completion toggle
- UpcomingEventsWidget: next 7 days of calendar events
- RecentContactsWidget: recently updated contacts
- QuoteOfTheDayWidget: daily rotating quote from zitare
- ActiveTimerWidget: running time tracker with live elapsed time
- RecentChatsWidget: last 3 AI conversations
- NutritionProgressWidget: calorie progress ring + macros
- PlantWateringWidget: plants due for watering
- QuickActionsWidget: new task/event/contact/note shortcuts
- WidgetGrid: responsive 3-column grid with error boundaries

All widgets query the shared IndexedDB directly — no cross-origin
hacks, no iFrame postMessage, just normal Dexie liveQuery.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 21:08:03 +02:00
parent 954923334f
commit fe052cc291
15 changed files with 1315 additions and 1 deletions

View file

@ -25,6 +25,12 @@ import MukkeLibraryWidget from './widgets/MukkeLibraryWidget.svelte';
import PresiDecksWidget from './widgets/PresiDecksWidget.svelte';
import ContextDocsWidget from './widgets/ContextDocsWidget.svelte';
// Phase 4: Unified app widgets (direct Dexie queries, internal routing)
import RecentContactsWidget from '$lib/modules/core/widgets/RecentContactsWidget.svelte';
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';
export const widgetComponents: Record<WidgetType, Component> = {
credits: CreditsWidget,
'quick-actions': QuickActionsWidget,
@ -34,6 +40,7 @@ export const widgetComponents: Record<WidgetType, Component> = {
'calendar-events': CalendarEventsWidget,
'chat-recent': ChatRecentWidget,
'contacts-favorites': ContactsFavoritesWidget,
'contacts-recent': RecentContactsWidget,
'zitare-quote': ZitareQuoteWidget,
'picture-recent': PictureRecentWidget,
'cards-progress': CardsProgressWidget,
@ -42,4 +49,7 @@ export const widgetComponents: Record<WidgetType, Component> = {
'mukke-library': MukkeLibraryWidget,
'presi-decks': PresiDecksWidget,
'context-docs': ContextDocsWidget,
'active-timer': ActiveTimerWidget,
'nutrition-progress': NutritionProgressWidget,
'plant-watering': PlantWateringWidget,
};

View file

@ -126,6 +126,22 @@
"documents": "Dokumente",
"empty": "Keine Dokumente",
"open": "Context öffnen"
},
"contacts_recent": {
"title": "Letzte Kontakte",
"description": "Kürzlich aktualisierte Kontakte"
},
"active_timer": {
"title": "Zeiterfassung",
"description": "Laufender Timer"
},
"nutrition": {
"title": "Ernährung",
"description": "Heutiger Kalorienfortschritt"
},
"plant_watering": {
"title": "Pflanzenpflege",
"description": "Pflanzen die gegossen werden müssen"
}
}
},

View file

@ -126,6 +126,22 @@
"documents": "Documents",
"empty": "No documents",
"open": "Open Context"
},
"contacts_recent": {
"title": "Recent Contacts",
"description": "Recently updated contacts"
},
"active_timer": {
"title": "Time Tracking",
"description": "Running timer"
},
"nutrition": {
"title": "Nutrition",
"description": "Today's calorie progress"
},
"plant_watering": {
"title": "Plant Care",
"description": "Plants that need watering"
}
}
},

View file

@ -0,0 +1,147 @@
<script lang="ts">
/**
* ActiveTimerWidget — Zeigt laufende Timer aus Times.
*
* Liest direkt aus der unified IndexedDB (timeEntries + timeProjects tables).
* Aktualisiert die verstrichene Zeit per requestAnimationFrame.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { onDestroy } from 'svelte';
import type { BaseRecord } from '@manacore/local-store';
interface TimeEntry extends BaseRecord {
projectId?: string | null;
description: string;
date: string;
startTime?: string | null;
endTime?: string | null;
duration: number;
isRunning: boolean;
}
interface TimeProject extends BaseRecord {
name: string;
color: string;
}
let runningEntry: (TimeEntry & { projectName?: string; projectColor?: string }) | null =
$state(null);
let loading = $state(true);
let elapsed = $state('00:00:00');
let animFrameId: number | null = null;
$effect(() => {
const sub = liveQuery(async () => {
const entries = await db.table<TimeEntry>('timeEntries').toArray();
const running = entries.find((e) => e.isRunning && !e.deletedAt);
if (!running) return null;
let projectName: string | undefined;
let projectColor: string | undefined;
if (running.projectId) {
const proj = await db.table<TimeProject>('timeProjects').get(running.projectId);
if (proj) {
projectName = proj.name;
projectColor = proj.color;
}
}
return { ...running, projectName, projectColor };
}).subscribe({
next: (val) => {
runningEntry = val;
loading = false;
},
error: () => {
loading = false;
},
});
return () => sub.unsubscribe();
});
// Tick the elapsed timer
$effect(() => {
if (animFrameId !== null) {
cancelAnimationFrame(animFrameId);
animFrameId = null;
}
if (!runningEntry?.startTime) {
elapsed = '00:00:00';
return;
}
const entryDate = runningEntry.date;
const startTimeStr = runningEntry.startTime;
function tick() {
const startDateTime = new Date(`${entryDate}T${startTimeStr}`);
const now = new Date();
const diffSec = Math.max(0, Math.floor((now.getTime() - startDateTime.getTime()) / 1000));
const h = Math.floor(diffSec / 3600);
const m = Math.floor((diffSec % 3600) / 60);
const s = diffSec % 60;
elapsed = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
animFrameId = requestAnimationFrame(tick);
}
tick();
});
onDestroy(() => {
if (animFrameId !== null) cancelAnimationFrame(animFrameId);
});
</script>
<div>
<div class="mb-3">
<h3 class="flex items-center gap-2 text-lg font-semibold">Zeiterfassung</h3>
</div>
{#if loading}
<div class="h-16 animate-pulse rounded bg-surface-hover"></div>
{:else if !runningEntry}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">&#9201;</div>
<p class="text-sm text-muted-foreground">Kein Timer aktiv.</p>
<a
href="/times"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Timer starten
</a>
</div>
{:else}
<a href="/times" class="block rounded-lg p-3 transition-colors hover:bg-surface-hover">
<div class="flex items-center gap-3">
{#if runningEntry.projectColor}
<div
class="h-3 w-3 flex-shrink-0 rounded-full"
style="background-color: {runningEntry.projectColor}"
></div>
{/if}
<div class="min-w-0 flex-1">
{#if runningEntry.projectName}
<p class="text-xs font-medium text-muted-foreground">
{runningEntry.projectName}
</p>
{/if}
<p class="truncate text-sm font-medium">
{runningEntry.description || 'Ohne Beschreibung'}
</p>
</div>
</div>
<div class="mt-3 flex items-center justify-center">
<div class="flex items-center gap-2 rounded-lg bg-primary/10 px-4 py-2">
<div class="h-2 w-2 animate-pulse rounded-full bg-green-500"></div>
<span class="font-mono text-lg font-bold text-primary">{elapsed}</span>
</div>
</div>
</a>
{/if}
</div>

View file

@ -0,0 +1,176 @@
<script lang="ts">
/**
* NutritionProgressWidget — Heutiger Kalorienfortschritt.
*
* Liest direkt aus der unified IndexedDB (meals + goals tables).
* Zeigt einen Fortschrittsbalken zum Tagesziel.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { BaseRecord } from '@manacore/local-store';
interface Meal extends BaseRecord {
date: string;
mealType: string;
description: string;
nutrition?: {
calories: number;
protein: number;
carbohydrates: number;
fat: number;
} | null;
}
interface Goal extends BaseRecord {
dailyCalories: number;
dailyProtein?: number | null;
dailyCarbs?: number | null;
dailyFat?: number | null;
}
const DEFAULT_CALORIES = 2000;
const todayStr = new Date().toISOString().split('T')[0];
let totalCalories = $state(0);
let targetCalories = $state(DEFAULT_CALORIES);
let totalProtein = $state(0);
let totalCarbs = $state(0);
let totalFat = $state(0);
let mealCount = $state(0);
let loading = $state(true);
$effect(() => {
const sub = liveQuery(async () => {
const [meals, goals] = await Promise.all([
db.table<Meal>('meals').toArray(),
db.table<Goal>('goals').toArray(),
]);
const todayMeals = meals.filter((m) => {
if (m.deletedAt) return false;
return String(m.date).split('T')[0] === todayStr;
});
const cals = todayMeals.reduce((sum, m) => sum + (m.nutrition?.calories || 0), 0);
const prot = todayMeals.reduce((sum, m) => sum + (m.nutrition?.protein || 0), 0);
const carbs = todayMeals.reduce((sum, m) => sum + (m.nutrition?.carbohydrates || 0), 0);
const fat = todayMeals.reduce((sum, m) => sum + (m.nutrition?.fat || 0), 0);
const activeGoal = goals.find((g) => !g.deletedAt);
const target = activeGoal?.dailyCalories || DEFAULT_CALORIES;
return { cals, prot, carbs, fat, target, count: todayMeals.length };
}).subscribe({
next: (val) => {
totalCalories = Math.round(val.cals);
totalProtein = Math.round(val.prot);
totalCarbs = Math.round(val.carbs);
totalFat = Math.round(val.fat);
targetCalories = val.target;
mealCount = val.count;
loading = false;
},
error: () => {
loading = false;
},
});
return () => sub.unsubscribe();
});
const percentage = $derived(Math.min(Math.round((totalCalories / targetCalories) * 100), 100));
// SVG circle progress
const RADIUS = 36;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const strokeOffset = $derived(CIRCUMFERENCE - (percentage / 100) * CIRCUMFERENCE);
function getProgressColor(pct: number): string {
if (pct < 50) return '#22c55e';
if (pct < 80) return '#eab308';
return '#f97316';
}
</script>
<div>
<div class="mb-3">
<h3 class="flex items-center gap-2 text-lg font-semibold">Ernährung heute</h3>
</div>
{#if loading}
<div class="flex items-center justify-center py-6">
<div class="h-20 w-20 animate-pulse rounded-full bg-surface-hover"></div>
</div>
{:else if mealCount === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">&#127860;</div>
<p class="text-sm text-muted-foreground">Noch keine Mahlzeiten erfasst.</p>
<a
href="/nutriphi"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Mahlzeit erfassen
</a>
</div>
{:else}
<a href="/nutriphi" class="block rounded-lg p-2 transition-colors hover:bg-surface-hover">
<!-- Progress Ring -->
<div class="mb-3 flex items-center justify-center">
<div class="relative">
<svg width="88" height="88" viewBox="0 0 88 88" class="-rotate-90">
<circle
cx="44"
cy="44"
r={RADIUS}
fill="none"
stroke="currentColor"
stroke-width="6"
class="text-surface-hover"
/>
<circle
cx="44"
cy="44"
r={RADIUS}
fill="none"
stroke={getProgressColor(percentage)}
stroke-width="6"
stroke-linecap="round"
stroke-dasharray={CIRCUMFERENCE}
stroke-dashoffset={strokeOffset}
class="transition-all duration-500"
/>
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<span class="text-lg font-bold">{percentage}%</span>
</div>
</div>
</div>
<!-- Stats -->
<div class="text-center text-sm">
<p class="font-medium">
{totalCalories} / {targetCalories} kcal
</p>
<p class="mt-1 text-xs text-muted-foreground">
{mealCount} Mahlzeit{mealCount !== 1 ? 'en' : ''}
</p>
</div>
<!-- Macros -->
<div class="mt-3 grid grid-cols-3 gap-2 text-center text-xs">
<div>
<p class="font-semibold text-red-400">{totalProtein}g</p>
<p class="text-muted-foreground">Protein</p>
</div>
<div>
<p class="font-semibold text-blue-400">{totalCarbs}g</p>
<p class="text-muted-foreground">Carbs</p>
</div>
<div>
<p class="font-semibold text-purple-400">{totalFat}g</p>
<p class="text-muted-foreground">Fett</p>
</div>
</div>
</a>
{/if}
</div>

View file

@ -0,0 +1,164 @@
<script lang="ts">
/**
* PlantWateringWidget — Pflanzen die heute gegossen werden müssen.
*
* Liest direkt aus der unified IndexedDB (plants + wateringSchedules tables).
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { BaseRecord } from '@manacore/local-store';
interface Plant extends BaseRecord {
name: string;
isActive: boolean;
healthStatus?: string | null;
}
interface WateringSchedule extends BaseRecord {
plantId: string;
frequencyDays: number;
nextWateringAt?: string | null;
}
interface PlantWithWatering {
id: string;
name: string;
healthStatus?: string | null;
daysUntil: number;
nextWateringAt: string;
}
let plantsToWater: PlantWithWatering[] = $state([]);
let totalActive = $state(0);
let loading = $state(true);
$effect(() => {
const sub = liveQuery(async () => {
const [plants, schedules] = await Promise.all([
db.table<Plant>('plants').toArray(),
db.table<WateringSchedule>('wateringSchedules').toArray(),
]);
const activePlants = plants.filter((p) => p.isActive && !p.deletedAt);
const scheduleMap = new Map(schedules.filter((s) => !s.deletedAt).map((s) => [s.plantId, s]));
const now = new Date();
const result: PlantWithWatering[] = [];
for (const plant of activePlants) {
const schedule = scheduleMap.get(plant.id);
if (!schedule?.nextWateringAt) continue;
const nextDate = new Date(schedule.nextWateringAt);
const daysUntil = Math.ceil((nextDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
// Show plants due today or overdue
if (daysUntil <= 1) {
result.push({
id: plant.id,
name: plant.name,
healthStatus: plant.healthStatus,
daysUntil,
nextWateringAt: schedule.nextWateringAt,
});
}
}
return {
plants: result.sort((a, b) => a.daysUntil - b.daysUntil),
totalActive: activePlants.length,
};
}).subscribe({
next: (val) => {
plantsToWater = val.plants;
totalActive = val.totalActive;
loading = false;
},
error: () => {
loading = false;
},
});
return () => sub.unsubscribe();
});
function getHealthIcon(status?: string | null): string {
switch (status) {
case 'healthy':
return '&#127793;';
case 'needs_attention':
return '&#128310;';
case 'sick':
return '&#128308;';
default:
return '&#127793;';
}
}
function getDueLabel(daysUntil: number): string {
if (daysUntil < 0)
return `${Math.abs(daysUntil)} Tag${Math.abs(daysUntil) !== 1 ? 'e' : ''} überfällig`;
if (daysUntil === 0) return 'Heute fällig';
return 'Morgen fällig';
}
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">Pflanzenpflege</h3>
{#if plantsToWater.length > 0}
<span class="rounded-full bg-blue-500/10 px-2.5 py-0.5 text-sm font-medium text-blue-500">
{plantsToWater.length}
</span>
{/if}
</div>
{#if loading}
<div class="space-y-2">
{#each Array(3) as _}
<div class="h-10 animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else if totalActive === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">&#127793;</div>
<p class="text-sm text-muted-foreground">Noch keine Pflanzen angelegt.</p>
<a
href="/planta"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Pflanze hinzufügen
</a>
</div>
{:else if plantsToWater.length === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">&#128167;</div>
<p class="text-sm text-muted-foreground">Alle Pflanzen sind versorgt!</p>
<p class="mt-1 text-xs text-muted-foreground">{totalActive} aktive Pflanzen</p>
</div>
{:else}
<div class="space-y-2">
{#each plantsToWater as plant (plant.id)}
<a
href="/planta"
class="flex items-center gap-3 rounded-lg p-2 transition-colors hover:bg-surface-hover"
>
<span class="text-lg">{@html getHealthIcon(plant.healthStatus)}</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{plant.name}</p>
<p
class="text-xs {plant.daysUntil < 0 ? 'font-semibold text-red-500' : 'text-blue-500'}"
>
{getDueLabel(plant.daysUntil)}
</p>
</div>
<span class="text-lg">&#128167;</span>
</a>
{/each}
</div>
<a href="/planta" class="mt-3 block text-center text-sm text-primary hover:underline">
Alle Pflanzen anzeigen
</a>
{/if}
</div>

View file

@ -0,0 +1,61 @@
<script lang="ts">
/**
* QuickActionsWidget — Schnellzugriff-Buttons für häufige Aktionen.
*
* Navigiert direkt zu den jeweiligen App-Routen innerhalb der Unified App.
*/
const actions = [
{
href: '/todo',
icon: '&#9989;',
label: 'Neue Aufgabe',
description: 'Aufgabe erstellen',
},
{
href: '/calendar',
icon: '&#128197;',
label: 'Neuer Termin',
description: 'Termin erstellen',
},
{
href: '/contacts',
icon: '&#128100;',
label: 'Neuer Kontakt',
description: 'Kontakt anlegen',
},
{
href: '/context',
icon: '&#128221;',
label: 'Neue Notiz',
description: 'Dokument erstellen',
},
{
href: '/times',
icon: '&#9201;',
label: 'Timer starten',
description: 'Zeiterfassung starten',
},
];
</script>
<div>
<div class="mb-3">
<h3 class="flex items-center gap-2 text-lg font-semibold">Schnellzugriff</h3>
</div>
<div class="space-y-1">
{#each actions as action}
<a
href={action.href}
class="flex items-center gap-3 rounded-lg p-2.5 transition-colors hover:bg-surface-hover"
>
<span class="text-xl">{@html action.icon}</span>
<div>
<p class="text-sm font-medium">{action.label}</p>
<p class="text-xs text-muted-foreground">{action.description}</p>
</div>
</a>
{/each}
</div>
</div>

View file

@ -0,0 +1,85 @@
<script lang="ts">
/**
* QuoteOfTheDayWidget — Zufälliges Tageszitat aus Zitare-Favoriten.
*
* Liest direkt aus der unified IndexedDB (zitareFavorites table).
* Zeigt eine zufällige Favoriten-ID — das vollständige Zitat stammt
* aus dem eingebetteten Zitate-Katalog von Zitare.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { BaseRecord } from '@manacore/local-store';
interface ZitareFavorite extends BaseRecord {
quoteId: string;
}
let favorite: ZitareFavorite | null = $state(null);
let totalFavorites = $state(0);
let loading = $state(true);
// Use date as seed for consistent "daily" pick
const today = new Date().toISOString().slice(0, 10);
function hashStr(s: string): number {
let hash = 0;
for (let i = 0; i < s.length; i++) {
hash = (hash << 5) - hash + s.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
$effect(() => {
const sub = liveQuery(async () => {
const all = await db.table<ZitareFavorite>('zitareFavorites').toArray();
return all.filter((f) => !f.deletedAt);
}).subscribe({
next: (val) => {
totalFavorites = val.length;
if (val.length > 0) {
const idx = hashStr(today) % val.length;
favorite = val[idx];
} else {
favorite = null;
}
loading = false;
},
error: () => {
loading = false;
},
});
return () => sub.unsubscribe();
});
</script>
<div>
<div class="mb-3">
<h3 class="flex items-center gap-2 text-lg font-semibold">Zitat des Tages</h3>
</div>
{#if loading}
<div class="h-16 animate-pulse rounded bg-surface-hover"></div>
{:else if !favorite}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">&#128161;</div>
<p class="text-sm text-muted-foreground">Noch keine Lieblingszitate gespeichert.</p>
<a
href="/zitare"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Zitate entdecken
</a>
</div>
{:else}
<a href="/zitare" class="block rounded-lg p-3 transition-colors hover:bg-surface-hover">
<p class="mb-2 text-sm italic text-foreground/80">
Favorit #{favorite.quoteId}
</p>
<p class="text-xs text-muted-foreground">
{totalFavorites} Lieblingszitate gespeichert
</p>
</a>
{/if}
</div>

View file

@ -0,0 +1,112 @@
<script lang="ts">
/**
* RecentChatsWidget — Letzte 3 Unterhaltungen aus Chat.
*
* Liest direkt aus der unified IndexedDB (conversations + messages tables).
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { BaseRecord } from '@manacore/local-store';
interface Conversation extends BaseRecord {
title?: string;
isArchived?: boolean;
isPinned?: boolean;
}
interface Message extends BaseRecord {
conversationId: string;
sender: 'user' | 'assistant' | 'system';
}
let conversations: (Conversation & { messageCount: number })[] = $state([]);
let loading = $state(true);
$effect(() => {
const sub = liveQuery(async () => {
const allConvs = await db.table<Conversation>('conversations').toArray();
const allMsgs = await db.table<Message>('messages').toArray();
// Count messages per conversation
const msgCounts = new Map<string, number>();
for (const msg of allMsgs) {
if (msg.deletedAt) continue;
msgCounts.set(msg.conversationId, (msgCounts.get(msg.conversationId) || 0) + 1);
}
return allConvs
.filter((c) => !c.isArchived && !c.deletedAt)
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.slice(0, 3)
.map((c) => ({ ...c, messageCount: msgCounts.get(c.id) || 0 }));
}).subscribe({
next: (val) => {
conversations = val;
loading = false;
},
error: () => {
loading = false;
},
});
return () => sub.unsubscribe();
});
function formatTime(dateStr?: string): string {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
return date.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
}
</script>
<div>
<div class="mb-3">
<h3 class="flex items-center gap-2 text-lg font-semibold">Chats</h3>
</div>
{#if loading}
<div class="space-y-2">
{#each Array(3) as _}
<div class="h-10 animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else if conversations.length === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">&#128172;</div>
<p class="text-sm text-muted-foreground">Noch keine Unterhaltungen.</p>
<a
href="/chat"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Chat starten
</a>
</div>
{:else}
<div class="space-y-1">
{#each conversations as conv (conv.id)}
<a
href="/chat"
class="flex items-center gap-3 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover"
>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{conv.title || 'Neue Unterhaltung'}</p>
<p class="text-xs text-muted-foreground">
{conv.messageCount} Nachrichten
</p>
</div>
<span class="flex-shrink-0 text-xs text-muted-foreground">
{formatTime(conv.updatedAt)}
</span>
</a>
{/each}
</div>
<a href="/chat" class="mt-3 block text-center text-sm text-primary hover:underline">
Alle Chats anzeigen
</a>
{/if}
</div>

View file

@ -0,0 +1,108 @@
<script lang="ts">
/**
* RecentContactsWidget — Zuletzt aktualisierte Kontakte.
*
* Liest direkt aus der unified IndexedDB (contacts table).
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { BaseRecord } from '@manacore/local-store';
interface Contact extends BaseRecord {
firstName?: string;
lastName?: string;
email?: string;
company?: string;
isFavorite?: boolean;
isArchived?: boolean;
}
let contacts: Contact[] = $state([]);
let loading = $state(true);
$effect(() => {
const sub = liveQuery(async () => {
const all = await db.table<Contact>('contacts').toArray();
return all
.filter((c) => !c.deletedAt && !c.isArchived)
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.slice(0, 5);
}).subscribe({
next: (val) => {
contacts = val;
loading = false;
},
error: () => {
loading = false;
},
});
return () => sub.unsubscribe();
});
function getDisplayName(c: Contact): string {
const parts = [c.firstName, c.lastName].filter(Boolean);
return parts.length > 0 ? parts.join(' ') : c.email || 'Unbekannt';
}
function getInitials(c: Contact): string {
const name = getDisplayName(c);
const parts = name.split(' ');
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
</script>
<div>
<div class="mb-3">
<h3 class="flex items-center gap-2 text-lg font-semibold">Kontakte</h3>
</div>
{#if 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 contacts.length === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">&#128100;</div>
<p class="text-sm text-muted-foreground">Noch keine Kontakte vorhanden.</p>
<a
href="/contacts"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Kontakt hinzufügen
</a>
</div>
{:else}
<div class="space-y-2">
{#each contacts as contact (contact.id)}
<a
href="/contacts"
class="flex items-center gap-3 rounded-lg p-2 transition-colors hover:bg-surface-hover"
>
<div
class="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10 text-sm font-medium text-primary"
>
{getInitials(contact)}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{getDisplayName(contact)}</p>
{#if contact.company}
<p class="truncate text-xs text-muted-foreground">{contact.company}</p>
{:else if contact.email}
<p class="truncate text-xs text-muted-foreground">{contact.email}</p>
{/if}
</div>
</a>
{/each}
</div>
<a href="/contacts" class="mt-3 block text-center text-sm text-primary hover:underline">
Alle Kontakte anzeigen
</a>
{/if}
</div>

View file

@ -0,0 +1,151 @@
<script lang="ts">
/**
* TasksTodayWidget — Aufgaben fällig heute oder überfällig.
*
* Liest direkt aus der unified IndexedDB (tasks table).
* Reaktiv via Dexie liveQuery — aktualisiert automatisch.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { BaseRecord } from '@manacore/local-store';
interface Task extends BaseRecord {
title: string;
priority: 'low' | 'medium' | 'high' | 'urgent';
isCompleted: boolean;
completedAt?: string | null;
dueDate?: string | null;
dueTime?: string | null;
order: number;
subtasks?: { id: string; title: string; isCompleted: boolean; order: number }[] | null;
}
const priorityColors: Record<string, string> = {
urgent: '#ef4444',
high: '#f97316',
medium: '#eab308',
low: '#22c55e',
};
const todayStr = new Date().toISOString().slice(0, 10);
let tasks: Task[] = $state([]);
let loading = $state(true);
$effect(() => {
const sub = liveQuery(async () => {
const all = await db.table<Task>('tasks').toArray();
return all
.filter((t) => {
if (t.isCompleted || t.deletedAt) return false;
if (!t.dueDate) return false;
return t.dueDate.slice(0, 10) <= todayStr;
})
.sort((a, b) => a.order - b.order);
}).subscribe({
next: (val) => {
tasks = val;
loading = false;
},
error: () => {
loading = false;
},
});
return () => sub.unsubscribe();
});
const MAX_DISPLAY = 5;
const displayed = $derived(tasks.slice(0, MAX_DISPLAY));
const remaining = $derived(Math.max(0, tasks.length - MAX_DISPLAY));
function isOverdue(dueDate: string): boolean {
return dueDate.slice(0, 10) < todayStr;
}
function formatDue(dueDate: string): string {
if (dueDate.slice(0, 10) === todayStr) return 'Heute';
const d = new Date(dueDate);
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
}
async function toggleComplete(task: Task) {
await db.table('tasks').update(task.id, {
isCompleted: true,
completedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">Aufgaben heute</h3>
{#if tasks.length > 0}
<span class="rounded-full bg-primary/10 px-2.5 py-0.5 text-sm font-medium text-primary">
{tasks.length}
</span>
{/if}
</div>
{#if loading}
<div class="space-y-2">
{#each Array(4) as _}
<div class="h-8 animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else if tasks.length === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">&#127881;</div>
<p class="text-sm text-muted-foreground">Keine Aufgaben fällig — alles erledigt!</p>
</div>
{:else}
<div class="space-y-1">
{#each displayed as task (task.id)}
<a
href="/todo"
class="flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover"
>
<div
class="h-2 w-2 flex-shrink-0 rounded-full"
style="background-color: {priorityColors[task.priority] || priorityColors.medium}"
></div>
<button
onclick={(e) => {
e.stopPropagation();
e.preventDefault();
toggleComplete(task);
}}
class="flex h-4 w-4 flex-shrink-0 cursor-pointer items-center justify-center rounded border-2 border-muted-foreground/40 transition-colors hover:border-primary/60"
aria-label="Als erledigt markieren"
></button>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="truncate text-sm font-medium">{task.title}</p>
{#if task.dueDate}
<span
class="flex-shrink-0 text-xs {isOverdue(task.dueDate)
? 'text-red-500 font-semibold'
: 'text-muted-foreground'}"
>
{formatDue(task.dueDate)}
</span>
{/if}
</div>
</div>
</a>
{/each}
{#if remaining > 0}
<a
href="/todo"
class="block rounded-lg py-2 text-center text-sm text-primary hover:bg-primary/5"
>
+{remaining} weitere Aufgaben
</a>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,152 @@
<script lang="ts">
/**
* UpcomingEventsWidget — Termine der nächsten 7 Tage.
*
* Liest direkt aus der unified IndexedDB (events + calendars tables).
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { BaseRecord } from '@manacore/local-store';
interface CalendarEvent extends BaseRecord {
calendarId: string;
title: string;
startDate: string;
endDate: string;
allDay: boolean;
location?: string | null;
color?: string | null;
}
interface Calendar extends BaseRecord {
name: string;
color: string;
isVisible: boolean;
}
let events: (CalendarEvent & { calendarColor: string })[] = $state([]);
let loading = $state(true);
$effect(() => {
const sub = liveQuery(async () => {
const now = new Date();
const future = new Date(now);
future.setDate(future.getDate() + 7);
const nowStr = now.toISOString();
const futureStr = future.toISOString();
const [allEvents, calendars] = await Promise.all([
db.table<CalendarEvent>('events').toArray(),
db.table<Calendar>('calendars').toArray(),
]);
const calendarMap = new Map(calendars.map((c) => [c.id, c]));
return allEvents
.filter((e) => {
if (e.deletedAt) return false;
const cal = calendarMap.get(e.calendarId);
if (cal && !cal.isVisible) return false;
return e.startDate >= nowStr && e.startDate <= futureStr;
})
.sort((a, b) => a.startDate.localeCompare(b.startDate))
.map((e) => ({
...e,
calendarColor: e.color || calendarMap.get(e.calendarId)?.color || '#3B82F6',
}));
}).subscribe({
next: (val) => {
events = val;
loading = false;
},
error: () => {
loading = false;
},
});
return () => sub.unsubscribe();
});
const MAX_DISPLAY = 5;
const displayed = $derived(events.slice(0, MAX_DISPLAY));
const remaining = $derived(Math.max(0, events.length - MAX_DISPLAY));
function formatEventTime(event: CalendarEvent): string {
const start = new Date(event.startDate);
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
let dateStr = '';
if (start.toDateString() === today.toDateString()) {
dateStr = 'Heute';
} else if (start.toDateString() === tomorrow.toDateString()) {
dateStr = 'Morgen';
} else {
dateStr = start.toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
});
}
if (event.allDay) return dateStr;
const timeStr = start.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
return `${dateStr}, ${timeStr}`;
}
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">Termine</h3>
{#if events.length > 0}
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-sm font-medium text-primary">
{events.length}
</span>
{/if}
</div>
{#if 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 events.length === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">&#128197;</div>
<p class="text-sm text-muted-foreground">Keine Termine in den nächsten 7 Tagen.</p>
</div>
{:else}
<div class="space-y-2">
{#each displayed as event (event.id)}
<a
href="/calendar"
class="flex items-start gap-3 rounded-lg p-2 transition-colors hover:bg-surface-hover"
>
<div
class="mt-1 h-3 w-3 flex-shrink-0 rounded-full"
style="background-color: {event.calendarColor}"
></div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{event.title}</p>
<p class="text-xs text-muted-foreground">{formatEventTime(event)}</p>
{#if event.location}
<p class="truncate text-xs text-muted-foreground">&#128205; {event.location}</p>
{/if}
</div>
</a>
{/each}
{#if remaining > 0}
<a
href="/calendar"
class="block rounded-lg py-2 text-center text-sm text-primary hover:bg-primary/5"
>
+{remaining} weitere Termine
</a>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,55 @@
<script lang="ts">
/**
* WidgetGrid — Responsive Grid aller Dashboard-Widgets.
*
* Rendert alle Cross-App-Widgets in einem CSS-Grid mit konsistentem
* Card-Container. Jedes Widget ist eigenständig und benötigt keine Props.
*/
import TasksTodayWidget from './TasksTodayWidget.svelte';
import UpcomingEventsWidget from './UpcomingEventsWidget.svelte';
import RecentContactsWidget from './RecentContactsWidget.svelte';
import QuoteOfTheDayWidget from './QuoteOfTheDayWidget.svelte';
import ActiveTimerWidget from './ActiveTimerWidget.svelte';
import RecentChatsWidget from './RecentChatsWidget.svelte';
import NutritionProgressWidget from './NutritionProgressWidget.svelte';
import PlantWateringWidget from './PlantWateringWidget.svelte';
import QuickActionsWidget from './QuickActionsWidget.svelte';
const widgets = [
{ id: 'tasks-today', component: TasksTodayWidget },
{ id: 'upcoming-events', component: UpcomingEventsWidget },
{ id: 'active-timer', component: ActiveTimerWidget },
{ id: 'quick-actions', component: QuickActionsWidget },
{ id: 'recent-chats', component: RecentChatsWidget },
{ id: 'recent-contacts', component: RecentContactsWidget },
{ id: 'nutrition-progress', component: NutritionProgressWidget },
{ id: 'plant-watering', component: PlantWateringWidget },
{ id: 'quote-of-the-day', component: QuoteOfTheDayWidget },
];
</script>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each widgets as widget (widget.id)}
<div class="rounded-xl border border-border bg-card p-4 shadow-sm">
<svelte:boundary>
<svelte:component this={widget.component} />
{#snippet failed(error, reset)}
<div class="flex flex-col items-center justify-center py-6 text-center">
<div class="mb-2 text-2xl">&#9888;&#65039;</div>
<p class="mb-3 text-sm text-muted-foreground">
{(error as Error)?.message || 'Widget-Fehler'}
</p>
<button
type="button"
onclick={reset}
class="rounded-md bg-muted px-3 py-1 text-xs hover:bg-muted/80"
>
Erneut versuchen
</button>
</div>
{/snippet}
</svelte:boundary>
</div>
{/each}
</div>

View file

@ -0,0 +1,18 @@
/**
* Core Dashboard Widgets Phase 4 Cross-App Widgets
*
* Self-contained Svelte 5 components that query the unified IndexedDB
* database directly via Dexie liveQuery. No cross-origin hacks needed
* since all apps share one database in the unified ManaCore app.
*/
export { default as TasksTodayWidget } from './TasksTodayWidget.svelte';
export { default as UpcomingEventsWidget } from './UpcomingEventsWidget.svelte';
export { default as RecentContactsWidget } from './RecentContactsWidget.svelte';
export { default as QuoteOfTheDayWidget } from './QuoteOfTheDayWidget.svelte';
export { default as ActiveTimerWidget } from './ActiveTimerWidget.svelte';
export { default as RecentChatsWidget } from './RecentChatsWidget.svelte';
export { default as NutritionProgressWidget } from './NutritionProgressWidget.svelte';
export { default as PlantWateringWidget } from './PlantWateringWidget.svelte';
export { default as QuickActionsWidget } from './QuickActionsWidget.svelte';
export { default as WidgetGrid } from './WidgetGrid.svelte';

View file

@ -16,6 +16,7 @@ export type WidgetType =
| 'calendar-events' // Calendar API: upcoming events
| 'chat-recent' // Chat API: recent conversations
| 'contacts-favorites' // Contacts API: favorite contacts
| 'contacts-recent' // Contacts: recently updated
| 'zitare-quote' // Zitare API: daily inspiration quote
| 'picture-recent' // Picture API: recent generations
| 'cards-progress' // Cards API: learning progress
@ -23,7 +24,10 @@ export type WidgetType =
| 'storage-usage' // Storage: file storage stats
| 'mukke-library' // Mukke: music library stats
| 'presi-decks' // Presi: recent presentations
| 'context-docs'; // Context: recent documents & spaces
| '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
/**
* Widget size - maps to CSS Grid columns
@ -121,6 +125,9 @@ export interface WidgetMeta {
| 'mukke'
| 'presi'
| 'context'
| 'times'
| 'nutriphi'
| 'planta'
| 'mana-core-auth';
}
@ -271,6 +278,42 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
allowMultiple: false,
requiredBackend: 'context',
},
{
type: 'contacts-recent',
nameKey: 'dashboard.widgets.contacts_recent.title',
descriptionKey: 'dashboard.widgets.contacts_recent.description',
icon: '👤',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'contacts',
},
{
type: 'active-timer',
nameKey: 'dashboard.widgets.active_timer.title',
descriptionKey: 'dashboard.widgets.active_timer.description',
icon: '⏱️',
defaultSize: 'small',
allowMultiple: false,
requiredBackend: 'times',
},
{
type: 'nutrition-progress',
nameKey: 'dashboard.widgets.nutrition.title',
descriptionKey: 'dashboard.widgets.nutrition.description',
icon: '🍽️',
defaultSize: 'small',
allowMultiple: false,
requiredBackend: 'nutriphi',
},
{
type: 'plant-watering',
nameKey: 'dashboard.widgets.plant_watering.title',
descriptionKey: 'dashboard.widgets.plant_watering.description',
icon: '🌱',
defaultSize: 'small',
allowMultiple: false,
requiredBackend: 'planta',
},
];
/**