mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:41:08 +02:00
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:
parent
954923334f
commit
fe052cc291
15 changed files with 1315 additions and 1 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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">⏱</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>
|
||||
|
|
@ -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">🍴</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>
|
||||
|
|
@ -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 '🌱';
|
||||
case 'needs_attention':
|
||||
return '🔶';
|
||||
case 'sick':
|
||||
return '🔴';
|
||||
default:
|
||||
return '🌱';
|
||||
}
|
||||
}
|
||||
|
||||
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">🌱</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">💧</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">💧</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>
|
||||
|
|
@ -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: '✅',
|
||||
label: 'Neue Aufgabe',
|
||||
description: 'Aufgabe erstellen',
|
||||
},
|
||||
{
|
||||
href: '/calendar',
|
||||
icon: '📅',
|
||||
label: 'Neuer Termin',
|
||||
description: 'Termin erstellen',
|
||||
},
|
||||
{
|
||||
href: '/contacts',
|
||||
icon: '👤',
|
||||
label: 'Neuer Kontakt',
|
||||
description: 'Kontakt anlegen',
|
||||
},
|
||||
{
|
||||
href: '/context',
|
||||
icon: '📝',
|
||||
label: 'Neue Notiz',
|
||||
description: 'Dokument erstellen',
|
||||
},
|
||||
{
|
||||
href: '/times',
|
||||
icon: '⏱',
|
||||
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>
|
||||
|
|
@ -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">💡</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>
|
||||
|
|
@ -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">💬</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>
|
||||
|
|
@ -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">👤</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>
|
||||
|
|
@ -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">🎉</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>
|
||||
|
|
@ -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">📅</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">📍 {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>
|
||||
|
|
@ -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">⚠️</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>
|
||||
18
apps/manacore/apps/web/src/lib/modules/core/widgets/index.ts
Normal file
18
apps/manacore/apps/web/src/lib/modules/core/widgets/index.ts
Normal 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';
|
||||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue