feat(app-registry): wire up 4 modules + 7 routes + tier-patch validator

Resolves the cross-cutting drift that the app-registry sanity-test was
silently catching but BRANDING_ONLY exceptions papered over.

App-registry wiring:
- Register augur, broadcasts, invoices, timeline as workbench cards.
- Resolve agents↔ai-agents naming drift: workbench id is now `agents`
  (matches MANA_APPS + the /agents route URL); folder stays `ai-agents`
  for grouping with other ai-* modules.

Broadcast→broadcasts unification:
- module.config appId, MANA_APPS id, APP_ICONS key, all route appIds,
  and the redundant APP_URL_OVERRIDES entry — all aligned with the
  earlier folder rename so nothing diverges anymore.

Top-level routes for workbench-only modules:
- /goals, /myday, /kontext, /rituals, /automations, /activity — thin
  RoutePage wrappers around the existing module ListViews.
- /timeline becomes a real module (ListView extracted from the route),
  route shrinks to a 12-line wrapper.

Food unarchive:
- packages/shared-branding/src/mana-apps.ts: remove `archived: true`
  from food entry. The module is fully wired (registered, synced,
  routed, with AI tools); the flag was outdated.

i18n cleanup:
- Rename ai-agents → agents key in all 5 apps locales.
- Drop dead "observatory" key from all 5 nav locales (route folder was
  removed in 7bca16dfa).

New CI guard — scripts/validate-tier-patches.mjs:
- Scans for `LOCAL TIER PATCH — revert before release` markers.
- Default: informational list (does not fail).
- Strict mode (MANA_TIER_PATCH_STRICT=1) for release/RC pipeline.
- Wired into validate:all.

Spec update:
- registry.spec.ts WORKBENCH_ONLY/BRANDING_ONLY: documented Settings
  family + AI Studio surfaces + intentionally-internal modules so the
  drift guard fires only on real drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-28 22:21:41 +02:00
parent 8a5fad34df
commit fa299e3bf9
41 changed files with 812 additions and 556 deletions

View file

@ -84,6 +84,10 @@ import {
FilmStrip,
Hourglass,
HeartHalf,
Eye,
Megaphone,
Receipt,
ClockCounterClockwise,
} from '@mana/shared-icons';
// ── Apps with entity capabilities ───────────────────────────
@ -1113,7 +1117,9 @@ registerApp({
});
registerApp({
id: 'ai-agents',
// Public id matches MANA_APPS + the route URL `/agents`. The module
// folder is named `ai-agents` for grouping with other ai-* modules.
id: 'agents',
name: 'AI Agents',
color: '#8B5CF6',
icon: Flag,
@ -1492,3 +1498,71 @@ registerApp({
return quiz.id;
},
});
registerApp({
id: 'augur',
name: 'Augur',
color: '#7c3aed',
icon: Eye,
views: {
// Witness/Oracle modes live inside the ListView; detail (/augur/entry/[id])
// and recap (/augur/recap) navigate via goto().
list: { load: () => import('$lib/modules/augur/ListView.svelte') },
},
});
registerApp({
id: 'broadcasts',
name: 'Broadcasts',
color: '#6366f1',
icon: Megaphone,
views: {
// Detail (/broadcasts/[id]), new (/broadcasts/new) and settings live
// as SvelteKit routes; the workbench card hosts the ListView root.
list: { load: () => import('$lib/modules/broadcasts/ListView.svelte') },
},
contextMenuActions: [
{
id: 'new-campaign',
label: 'Neue Kampagne',
icon: Plus,
action: () => {
window.location.href = '/broadcasts/new';
},
},
],
});
registerApp({
id: 'invoices',
name: 'Rechnungen',
color: '#059669',
icon: Receipt,
views: {
// Detail (/invoices/[id]), new (/invoices/new) and settings live as
// SvelteKit routes; the workbench card hosts the ListView root.
list: { load: () => import('$lib/modules/invoices/ListView.svelte') },
},
contextMenuActions: [
{
id: 'new-invoice',
label: 'Neue Rechnung',
icon: Plus,
action: () => {
window.location.href = '/invoices/new';
},
},
],
});
registerApp({
id: 'timeline',
name: 'Timeline',
color: '#f59e0b',
icon: ClockCounterClockwise,
views: {
// Cross-module read-only view — reads timeBlocks owned by core.
// /timeline/analytics navigates via goto() from the route page.
list: { load: () => import('$lib/modules/timeline/ListView.svelte') },
},
});

View file

@ -45,7 +45,7 @@ export const APP_CATEGORY_MAP: Record<string, AppCategory> = {
// AI Workbench — the 6 new AI feature apps + companion chat
companion: 'ai',
'ai-missions': 'ai',
'ai-agents': 'ai',
agents: 'ai',
'ai-workbench': 'ai',
'ai-policy': 'ai',
'ai-insights': 'ai',

View file

@ -690,7 +690,7 @@ export const MODULE_HELP: Record<string, ModuleHelp> = {
'Der Debug-Log hilft zu verstehen warum die AI bestimmte Entscheidungen trifft',
],
},
'ai-agents': {
agents: {
description:
'Benannte AI-Personas mit eigenem System-Prompt, Policy und Gedächtnis. Jeder Agent kann eigene Missionen ausführen.',
features: [

View file

@ -14,7 +14,36 @@ import { getAllApps } from './registry';
* the kind of mismatch that produced the silent inventarinventory bug
* before commit 45790ffbb.
*/
const WORKBENCH_ONLY = new Set(['automations', 'playground']);
const WORKBENCH_ONLY = new Set([
// Dev / internal tools — never meant for MANA_APPS.
'automations',
'playground',
'admin',
'complexity',
'feedback',
// Settings family — surfaced as workbench cards but conceptually
// part of /settings, not standalone MANA_APPS entries.
'settings',
'themes',
'profile',
'credits',
'api-keys',
'help',
'spiral',
// User-facing modules that don't yet have MANA_APPS branding.
// Add them to MANA_APPS when they ship a marketing surface.
'kontext',
'rituals',
'wishes',
// AI Studio surfaces. Open question whether to expose each one in
// MANA_APPS or consolidate into a single "AI Studio" branding entry.
// Keeping them workbench-only until that decision lands.
'ai-missions',
'ai-workbench',
'ai-policy',
'ai-insights',
'ai-health',
]);
/**
* Apps that intentionally exist in MANA_APPS but are NOT in the workbench
@ -39,11 +68,6 @@ const BRANDING_ONLY = new Set([
// gallery as "Coming Soon" hints.
'wisekeep',
'mail',
'events',
// Status 'beta' but the workbench integration is still pending. Move
// out of this list once the apps.ts entry exists.
'guides',
'who',
]);
describe('app registry ↔ MANA_APPS consistency', () => {

View file

@ -35,7 +35,7 @@ import NewsUnreadWidget from '$lib/modules/news/widgets/NewsUnreadWidget.svelte'
import ArticlesUnreadWidget from '$lib/modules/articles/widgets/ArticlesUnreadWidget.svelte';
import BodyStatsWidget from '$lib/modules/body/widgets/BodyStatsWidget.svelte';
import InvoicesOpenWidget from '$lib/modules/invoices/widgets/InvoicesOpenWidget.svelte';
import BroadcastsWidget from '$lib/modules/broadcast/widgets/BroadcastsWidget.svelte';
import BroadcastsWidget from '$lib/modules/broadcasts/widgets/BroadcastsWidget.svelte';
import DayTimelineWidget from './widgets/DayTimelineWidget.svelte';
import ActivityFeedWidget from './widgets/ActivityFeedWidget.svelte';

View file

@ -87,7 +87,7 @@ import type {
LocalCampaign,
LocalBroadcastTemplate,
LocalBroadcastSettings,
} from '../../modules/broadcast/types';
} from '../../modules/broadcasts/types';
import type { LocalArticle, LocalHighlight } from '../../modules/articles/types';
import type { LocalMeImage } from '../../modules/profile/types';
import type { LocalWardrobeGarment, LocalWardrobeOutfit } from '../../modules/wardrobe/types';

View file

@ -102,7 +102,7 @@ import { profileModuleConfig } from '$lib/modules/profile/module.config';
import { libraryModuleConfig } from '$lib/modules/library/module.config';
import { articlesModuleConfig } from '$lib/modules/articles/module.config';
import { invoicesModuleConfig } from '$lib/modules/invoices/module.config';
import { broadcastModuleConfig } from '$lib/modules/broadcast/module.config';
import { broadcastModuleConfig } from '$lib/modules/broadcasts/module.config';
import { wetterModuleConfig } from '$lib/modules/wetter/module.config';
import { websiteModuleConfig } from '$lib/modules/website/module.config';
import { wardrobeModuleConfig } from '$lib/modules/wardrobe/module.config';

View file

@ -45,7 +45,7 @@ import { wetterTools } from '$lib/modules/wetter/tools';
import { quizTools } from '$lib/modules/quiz/tools';
import { invoicesTools } from '$lib/modules/invoices/tools';
import { libraryTools } from '$lib/modules/library/tools';
import { broadcastTools } from '$lib/modules/broadcast/tools';
import { broadcastTools } from '$lib/modules/broadcasts/tools';
import { websiteTools } from '$lib/modules/website/tools';
import { writingTools } from '$lib/modules/writing/tools';
import { comicTools } from '$lib/modules/comic/tools';

View file

@ -51,7 +51,7 @@
"activity": "Aktivität",
"companion": "Begleiter",
"ai-missions": "KI-Missionen",
"ai-agents": "KI-Agenten",
"agents": "KI-Agenten",
"ai-workbench": "KI-Werkbank",
"rituals": "Rituale",
"ai-policy": "KI-Richtlinien",

View file

@ -51,7 +51,7 @@
"activity": "Activity",
"companion": "Companion",
"ai-missions": "AI Missions",
"ai-agents": "AI Agents",
"agents": "AI Agents",
"ai-workbench": "AI Workbench",
"rituals": "Rituals",
"ai-policy": "AI Policy",

View file

@ -51,7 +51,7 @@
"activity": "Actividad",
"companion": "Compañero",
"ai-missions": "Misiones IA",
"ai-agents": "Agentes IA",
"agents": "Agentes IA",
"ai-workbench": "Workbench IA",
"rituals": "Rituales",
"ai-policy": "Políticas IA",

View file

@ -51,7 +51,7 @@
"activity": "Activité",
"companion": "Compagnon",
"ai-missions": "Missions IA",
"ai-agents": "Agents IA",
"agents": "Agents IA",
"ai-workbench": "Workbench IA",
"rituals": "Rituels",
"ai-policy": "Règles IA",

View file

@ -51,7 +51,7 @@
"activity": "Attività",
"companion": "Compagno",
"ai-missions": "Missioni IA",
"ai-agents": "Agenti IA",
"agents": "Agenti IA",
"ai-workbench": "Workbench IA",
"rituals": "Rituali",
"ai-policy": "Regole IA",

View file

@ -2,7 +2,6 @@
"home": "Home",
"dashboard": "Dashboard",
"spiral": "Spiral",
"observatory": "Observatory",
"credits": "Credits",
"gifts": "Geschenke",
"api_keys": "API Keys",

View file

@ -2,7 +2,6 @@
"home": "Home",
"dashboard": "Dashboard",
"spiral": "Spiral",
"observatory": "Observatory",
"credits": "Credits",
"gifts": "Gifts",
"api_keys": "API Keys",

View file

@ -2,7 +2,6 @@
"home": "Inicio",
"dashboard": "Panel",
"spiral": "Spiral",
"observatory": "Observatorio",
"credits": "Créditos",
"gifts": "Regalos",
"api_keys": "API Keys",

View file

@ -2,7 +2,6 @@
"home": "Accueil",
"dashboard": "Tableau de bord",
"spiral": "Spiral",
"observatory": "Observatoire",
"credits": "Crédits",
"gifts": "Cadeaux",
"api_keys": "Clés API",

View file

@ -2,7 +2,6 @@
"home": "Home",
"dashboard": "Dashboard",
"spiral": "Spiral",
"observatory": "Osservatorio",
"credits": "Crediti",
"gifts": "Regali",
"api_keys": "Chiavi API",

View file

@ -1,7 +1,7 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const broadcastModuleConfig: ModuleConfig = {
appId: 'broadcast',
appId: 'broadcasts',
tables: [
{ name: 'broadcastCampaigns' },
{ name: 'broadcastTemplates' },

View file

@ -80,7 +80,7 @@ export function toSettings(local: LocalBroadcastSettings): BroadcastSettings {
export function useAllCampaigns() {
return useScopedLiveQuery(async () => {
const rows = await scopedForModule<LocalCampaign, string>(
'broadcast',
'broadcasts',
'broadcastCampaigns'
).toArray();
const visible = rows.filter((r) => !r.deletedAt);
@ -92,7 +92,7 @@ export function useAllCampaigns() {
export function useAllTemplates() {
return useScopedLiveQuery(async () => {
const rows = await scopedForModule<LocalBroadcastTemplate, string>(
'broadcast',
'broadcasts',
'broadcastTemplates'
).toArray();
const visible = rows.filter((r) => !r.deletedAt);

View file

@ -70,7 +70,7 @@ export const broadcastCampaignsStore = {
await encryptRecord('broadcastCampaigns', newLocal);
await campaignTable.add(newLocal);
emitDomainEvent('BroadcastCampaignCreated', 'broadcast', 'broadcastCampaigns', newLocal.id, {
emitDomainEvent('BroadcastCampaignCreated', 'broadcasts', 'broadcastCampaigns', newLocal.id, {
campaignId: newLocal.id,
name: newLocal.name,
});
@ -142,7 +142,7 @@ export const broadcastCampaignsStore = {
status: 'scheduled' as CampaignStatus,
scheduledAt,
});
emitDomainEvent('BroadcastCampaignScheduled', 'broadcast', 'broadcastCampaigns', id, {
emitDomainEvent('BroadcastCampaignScheduled', 'broadcasts', 'broadcastCampaigns', id, {
campaignId: id,
scheduledAt,
});
@ -159,7 +159,7 @@ export const broadcastCampaignsStore = {
status: 'cancelled' as CampaignStatus,
scheduledAt: null,
});
emitDomainEvent('BroadcastCampaignCancelled', 'broadcast', 'broadcastCampaigns', id, {
emitDomainEvent('BroadcastCampaignCancelled', 'broadcasts', 'broadcastCampaigns', id, {
campaignId: id,
});
},
@ -198,7 +198,7 @@ export const broadcastCampaignsStore = {
await campaignTable.update(id, {
deletedAt: new Date().toISOString(),
});
emitDomainEvent('BroadcastCampaignDeleted', 'broadcast', 'broadcastCampaigns', id, {
emitDomainEvent('BroadcastCampaignDeleted', 'broadcasts', 'broadcastCampaigns', id, {
campaignId: id,
});
},

View file

@ -53,7 +53,7 @@ export const broadcastSettingsStore = {
});
emitDomainEvent(
'BroadcastSettingsUpdated',
'broadcast',
'broadcasts',
'broadcastSettings',
BROADCAST_SETTINGS_ID,
{ fields: Object.keys(patch) }

View file

@ -60,7 +60,7 @@ async function listDecryptedCampaigns(): Promise<ReturnType<typeof toCampaign>[]
export const broadcastTools: ModuleTool[] = [
{
name: 'create_campaign_draft',
module: 'broadcast',
module: 'broadcasts',
description:
'Erstellt einen Newsletter-/Kampagnen-Entwurf mit Name, Betreff, optionalem Preheader und fertigem HTML-Body.',
parameters: [
@ -97,7 +97,7 @@ export const broadcastTools: ModuleTool[] = [
{
name: 'list_campaigns',
module: 'broadcast',
module: 'broadcasts',
description: 'Listet Kampagnen (id, name, subject, status, Empfängerzahl, sentAt).',
parameters: [
{
@ -132,7 +132,7 @@ export const broadcastTools: ModuleTool[] = [
{
name: 'get_campaign_stats',
module: 'broadcast',
module: 'broadcasts',
description: 'Gibt Raten zu einer Kampagne zurück: Öffnungs-, Klick-, Bounce- und Abmelderate.',
parameters: [{ name: 'campaignId', type: 'string', description: 'ID', required: true }],
async execute(params) {

View file

@ -8,10 +8,10 @@
*/
import { liveQuery } from 'dexie';
import { campaignTable } from '$lib/modules/broadcast/collections';
import { campaignTable } from '$lib/modules/broadcasts/collections';
import { decryptRecords } from '$lib/data/crypto';
import { toCampaign, computeStats, formatRate } from '$lib/modules/broadcast/queries';
import type { Campaign, LocalCampaign } from '$lib/modules/broadcast/types';
import { toCampaign, computeStats, formatRate } from '$lib/modules/broadcasts/queries';
import type { Campaign, LocalCampaign } from '$lib/modules/broadcasts/types';
let campaigns = $state<Campaign[]>([]);
let loading = $state(true);

View file

@ -0,0 +1,506 @@
<!--
Timeline — Chronological day view of all timeBlocks.
"What did I do today?" as a standalone page.
-->
<script lang="ts">
import { getDateFnsLocale } from '$lib/i18n/format';
import { _ } from 'svelte-i18n';
import { useLiveQueryWithDefault } from '@mana/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 { getIconComponent } from '@mana/shared-icons';
import {
CaretLeft,
CaretRight,
CalendarBlank,
CheckSquare,
Timer,
Heart,
Lightning,
Clock,
Funnel,
} from '@mana/shared-icons';
import { format, addDays, subDays, isToday, isTomorrow, isYesterday } from 'date-fns';
let currentDate = $state(new Date());
let showFilters = $state(false);
let visibleTypes = $state<Set<TimeBlockType>>(
new Set(['event', 'task', 'habit', 'timeEntry', 'focus', 'break'])
);
let dateStr = $derived(format(currentDate, 'yyyy-MM-dd'));
let dayStart = $derived(`${dateStr}T00:00:00.000Z`);
let dayEnd = $derived(`${dateStr}T23:59:59.999Z`);
const blocksQuery = useLiveQueryWithDefault(async () => {
const locals = await db
.table<LocalTimeBlock>('timeBlocks')
.where('startDate')
.between(dayStart, dayEnd, true, true)
.toArray();
return locals
.filter((b) => !b.deletedAt)
.map(toTimeBlock)
.sort((a, b) => a.startDate.localeCompare(b.startDate));
}, [] as TimeBlock[]);
let allBlocks = $derived(blocksQuery.value ?? []);
let blocks = $derived(allBlocks.filter((b) => visibleTypes.has(b.type)));
// Stats
let totalSeconds = $derived(blocks.reduce((sum, b) => sum + getBlockDuration(b), 0));
let liveBlock = $derived(blocks.find((b) => b.isLive));
const typeConfig: {
type: TimeBlockType;
icon: typeof CalendarBlank;
label: string;
color: string;
}[] = [
{ type: 'event', icon: CalendarBlank, label: 'Termine', color: '#3b82f6' },
{ type: 'task', icon: CheckSquare, label: 'Aufgaben', color: '#f59e0b' },
{ type: 'timeEntry', icon: Timer, label: 'Zeiten', color: '#8b5cf6' },
{ type: 'habit', icon: Heart, label: 'Habits', color: '#22c55e' },
{ type: 'focus', icon: Lightning, label: 'Fokus', color: '#ef4444' },
];
function toggleType(type: TimeBlockType) {
const next = new Set(visibleTypes);
if (next.has(type)) next.delete(type);
else next.add(type);
visibleTypes = next;
}
function formatHeaderDate(date: Date): string {
if (isToday(date)) return 'Heute';
if (isTomorrow(date)) return 'Morgen';
if (isYesterday(date)) return 'Gestern';
return format(date, 'EEEE, d. MMMM yyyy', { locale: getDateFnsLocale() });
}
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;
return `${start} — ${format(new Date(block.endDate), 'HH:mm')}`;
}
function formatDuration(seconds: number): string {
if (seconds === 0) return '';
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`;
}
function getTypeColor(type: TimeBlockType): string {
return typeConfig.find((c) => c.type === type)?.color ?? '#6b7280';
}
</script>
<div class="timeline-page">
<header class="timeline-header">
<div class="header-left">
<h1 class="header-title">{formatHeaderDate(currentDate)}</h1>
<div class="nav-buttons">
<button onclick={() => (currentDate = subDays(currentDate, 1))} class="nav-btn">
<CaretLeft size={18} />
</button>
<button onclick={() => (currentDate = new Date())} class="today-btn">Heute</button>
<button onclick={() => (currentDate = addDays(currentDate, 1))} class="nav-btn">
<CaretRight size={18} />
</button>
</div>
</div>
<div class="header-right">
{#if totalSeconds > 0}
<span class="total-duration">{formatDuration(totalSeconds)} erfasst</span>
{/if}
<button
class="filter-btn"
class:active={visibleTypes.size < 6}
onclick={() => (showFilters = !showFilters)}
>
<Funnel size={16} />
</button>
</div>
</header>
{#if showFilters}
<div class="filter-bar">
{#each typeConfig as cfg}
{@const active = visibleTypes.has(cfg.type)}
{@const Icon = cfg.icon}
<button class="filter-chip" class:active onclick={() => toggleType(cfg.type)}>
<Icon size={14} />
{cfg.label}
</button>
{/each}
</div>
{/if}
<!-- Timeline content -->
<div class="timeline-content">
{#if blocks.length === 0}
<div class="empty">
<Clock size={48} class="empty-icon" />
<p>{isToday(currentDate) ? 'Noch nichts heute' : 'Keine Einträge an diesem Tag'}</p>
</div>
{:else}
<div class="timeline-list">
{#each blocks as block, i (block.id)}
{@const duration = getBlockDuration(block)}
{@const habitIcon =
block.type === 'habit' && block.icon ? getIconComponent(block.icon) : null}
{@const typeCfg = typeConfig.find((c) => c.type === block.type)}
<div class="timeline-item" class:live={block.isLive}>
<!-- Time column -->
<div class="time-col">
<span class="time-label">{format(new Date(block.startDate), 'HH:mm')}</span>
</div>
<!-- Dot + line -->
<div class="dot-col">
<div
class="dot"
class:live={block.isLive}
style="background: {block.color || getTypeColor(block.type)}"
></div>
{#if i < blocks.length - 1}
<div class="connector-line"></div>
{/if}
</div>
<!-- Content -->
<div class="content-col">
<div class="item-header">
{#if habitIcon}
{@const HabitIcon = habitIcon}
<HabitIcon size={16} style="color: {block.color || '#6b7280'}" />
{:else if typeCfg}
{@const TypeIcon = typeCfg.icon}
<TypeIcon size={16} class="item-type-icon" />
{/if}
<span class="item-title">{block.title}</span>
{#if block.linkedBlockId}
<span class="linked-badge">erledigt</span>
{/if}
{#if block.isLive}
<span class="live-badge">live</span>
{/if}
</div>
<div class="item-meta">
<span>{formatBlockTime(block)}</span>
{#if duration > 0}
<span class="duration-pill">{formatDuration(duration)}</span>
{/if}
</div>
{#if block.description}
<p class="item-description">{block.description}</p>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<style>
.timeline-page {
display: flex;
flex-direction: column;
height: 100%;
background: hsl(var(--color-background));
}
.timeline-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid hsl(var(--color-border));
flex-wrap: wrap;
gap: 0.5rem;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.header-title {
font-size: 1.25rem;
font-weight: 700;
color: hsl(var(--color-foreground));
margin: 0;
}
.nav-buttons {
display: flex;
align-items: center;
gap: 0.25rem;
}
.nav-btn {
padding: 0.375rem;
border: none;
background: transparent;
border-radius: var(--radius-md, 8px);
cursor: pointer;
color: hsl(var(--color-muted-foreground));
}
.nav-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.today-btn {
padding: 0.25rem 0.75rem;
border: none;
background: transparent;
border-radius: var(--radius-md, 8px);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.today-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.header-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.total-duration {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.filter-btn {
padding: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
border-radius: var(--radius-md, 8px);
cursor: pointer;
color: hsl(var(--color-muted-foreground));
}
.filter-btn:hover {
background: hsl(var(--color-muted));
}
.filter-btn.active {
background: hsl(var(--color-primary) / 0.1);
border-color: hsl(var(--color-primary) / 0.3);
color: hsl(var(--color-primary));
}
.filter-bar {
display: flex;
gap: 0.375rem;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.filter-chip {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 9999px;
background: transparent;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
}
.filter-chip:hover {
background: hsl(var(--color-muted));
}
.filter-chip.active {
background: hsl(var(--color-primary) / 0.1);
border-color: hsl(var(--color-primary) / 0.3);
color: hsl(var(--color-primary));
}
/* Timeline content */
.timeline-content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 4rem 0;
color: hsl(var(--color-muted-foreground));
}
.empty p {
font-size: 0.875rem;
}
.timeline-list {
display: flex;
flex-direction: column;
}
.timeline-item {
display: flex;
gap: 0;
min-height: 3.5rem;
}
.timeline-item.live {
background: hsl(var(--color-primary) / 0.03);
border-radius: var(--radius-md, 8px);
}
/* Time column */
.time-col {
width: 3.5rem;
flex-shrink: 0;
padding-top: 0.75rem;
text-align: right;
padding-right: 0.75rem;
}
.time-label {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
/* Dot + connector */
.dot-col {
width: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-top: 0.85rem;
flex-shrink: 0;
z-index: 1;
}
.dot.live {
animation: pulse-dot 2s ease-in-out infinite;
}
@keyframes pulse-dot {
0%,
100% {
box-shadow: 0 0 0 0 currentColor;
}
50% {
box-shadow: 0 0 0 4px currentColor;
opacity: 0.3;
}
}
.connector-line {
width: 2px;
flex: 1;
background: hsl(var(--color-border));
margin-top: 0.25rem;
}
/* Content */
.content-col {
flex: 1;
padding: 0.5rem 0 1rem 0.5rem;
min-width: 0;
}
.item-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.item-title {
font-size: 0.9375rem;
font-weight: 500;
color: hsl(var(--color-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.linked-badge {
font-size: 0.625rem;
font-weight: 600;
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
background: hsl(var(--color-success, 142 71% 45%) / 0.15);
color: hsl(var(--color-success, 142 71% 45%));
flex-shrink: 0;
}
.live-badge {
font-size: 0.625rem;
font-weight: 600;
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
animation: pulse-badge 2s ease-in-out infinite;
flex-shrink: 0;
}
@keyframes pulse-badge {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.item-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.125rem;
}
.duration-pill {
padding: 0 0.375rem;
border-radius: 9999px;
background: hsl(var(--color-muted));
font-size: 0.6875rem;
}
.item-description {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.4;
}
</style>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import ListView from '$lib/modules/activity/ListView.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Aktivität - Mana</title>
</svelte:head>
<RoutePage appId="activity">
<ListView />
</RoutePage>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import ListView from '$lib/modules/automations/ListView.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Automations - Mana</title>
</svelte:head>
<RoutePage appId="automations">
<ListView navigate={() => {}} goBack={() => history.back()} params={{}} />
</RoutePage>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import ListView from '$lib/modules/broadcast/ListView.svelte';
import ListView from '$lib/modules/broadcasts/ListView.svelte';
import { RoutePage } from '$lib/components/shell';
</script>

View file

@ -1,8 +1,8 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { useAllCampaigns } from '$lib/modules/broadcast/queries';
import DetailView from '$lib/modules/broadcast/views/DetailView.svelte';
import { useAllCampaigns } from '$lib/modules/broadcasts/queries';
import DetailView from '$lib/modules/broadcasts/views/DetailView.svelte';
import { RoutePage } from '$lib/components/shell';
const campaigns$ = useAllCampaigns();

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { useAllCampaigns } from '$lib/modules/broadcast/queries';
import ComposeView from '$lib/modules/broadcast/views/ComposeView.svelte';
import { useAllCampaigns } from '$lib/modules/broadcasts/queries';
import ComposeView from '$lib/modules/broadcasts/views/ComposeView.svelte';
import { RoutePage } from '$lib/components/shell';
const campaigns$ = useAllCampaigns();

View file

@ -6,7 +6,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { broadcastCampaignsStore } from '$lib/modules/broadcast/stores/campaigns.svelte';
import { broadcastCampaignsStore } from '$lib/modules/broadcasts/stores/campaigns.svelte';
import { RoutePage } from '$lib/components/shell';
let error = $state<string | null>(null);

View file

@ -3,7 +3,7 @@
Reached via the ⚙ button in the broadcast module; not a workbench card.
-->
<script lang="ts">
import SettingsForm from '$lib/modules/broadcast/components/SettingsForm.svelte';
import SettingsForm from '$lib/modules/broadcasts/components/SettingsForm.svelte';
import { RoutePage } from '$lib/components/shell';
</script>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import ListView from '$lib/modules/goals/ListView.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Ziele - Mana</title>
</svelte:head>
<RoutePage appId="goals">
<ListView />
</RoutePage>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import KontextView from '$lib/modules/kontext/KontextView.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Web-Context - Mana</title>
</svelte:head>
<RoutePage appId="kontext">
<KontextView />
</RoutePage>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import ListView from '$lib/modules/myday/ListView.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Mein Tag - Mana</title>
</svelte:head>
<RoutePage appId="myday">
<ListView />
</RoutePage>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import ListView from '$lib/modules/rituals/ListView.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Rituale - Mana</title>
</svelte:head>
<RoutePage appId="rituals">
<ListView />
</RoutePage>

View file

@ -1,510 +1,12 @@
<!--
Timeline — Chronological day view of all timeBlocks.
"What did I do today?" as a standalone page.
-->
<script lang="ts">
import { getDateFnsLocale } from '$lib/i18n/format';
import { _ } from 'svelte-i18n';
import { useLiveQueryWithDefault } from '@mana/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 { getIconComponent } from '@mana/shared-icons';
import {
CaretLeft,
CaretRight,
CalendarBlank,
CheckSquare,
Timer,
Heart,
Lightning,
Clock,
Funnel,
} from '@mana/shared-icons';
import { format, addDays, subDays, isToday, isTomorrow, isYesterday } from 'date-fns';
import ListView from '$lib/modules/timeline/ListView.svelte';
import { RoutePage } from '$lib/components/shell';
let currentDate = $state(new Date());
let showFilters = $state(false);
let visibleTypes = $state<Set<TimeBlockType>>(
new Set(['event', 'task', 'habit', 'timeEntry', 'focus', 'break'])
);
let dateStr = $derived(format(currentDate, 'yyyy-MM-dd'));
let dayStart = $derived(`${dateStr}T00:00:00.000Z`);
let dayEnd = $derived(`${dateStr}T23:59:59.999Z`);
const blocksQuery = useLiveQueryWithDefault(async () => {
const locals = await db
.table<LocalTimeBlock>('timeBlocks')
.where('startDate')
.between(dayStart, dayEnd, true, true)
.toArray();
return locals
.filter((b) => !b.deletedAt)
.map(toTimeBlock)
.sort((a, b) => a.startDate.localeCompare(b.startDate));
}, [] as TimeBlock[]);
let allBlocks = $derived(blocksQuery.value ?? []);
let blocks = $derived(allBlocks.filter((b) => visibleTypes.has(b.type)));
// Stats
let totalSeconds = $derived(blocks.reduce((sum, b) => sum + getBlockDuration(b), 0));
let liveBlock = $derived(blocks.find((b) => b.isLive));
const typeConfig: {
type: TimeBlockType;
icon: typeof CalendarBlank;
label: string;
color: string;
}[] = [
{ type: 'event', icon: CalendarBlank, label: 'Termine', color: '#3b82f6' },
{ type: 'task', icon: CheckSquare, label: 'Aufgaben', color: '#f59e0b' },
{ type: 'timeEntry', icon: Timer, label: 'Zeiten', color: '#8b5cf6' },
{ type: 'habit', icon: Heart, label: 'Habits', color: '#22c55e' },
{ type: 'focus', icon: Lightning, label: 'Fokus', color: '#ef4444' },
];
function toggleType(type: TimeBlockType) {
const next = new Set(visibleTypes);
if (next.has(type)) next.delete(type);
else next.add(type);
visibleTypes = next;
}
function formatHeaderDate(date: Date): string {
if (isToday(date)) return 'Heute';
if (isTomorrow(date)) return 'Morgen';
if (isYesterday(date)) return 'Gestern';
return format(date, 'EEEE, d. MMMM yyyy', { locale: getDateFnsLocale() });
}
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;
return `${start} — ${format(new Date(block.endDate), 'HH:mm')}`;
}
function formatDuration(seconds: number): string {
if (seconds === 0) return '';
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`;
}
function getTypeColor(type: TimeBlockType): string {
return typeConfig.find((c) => c.type === type)?.color ?? '#6b7280';
}
</script>
<svelte:head>
<title>Timeline - Mana</title>
</svelte:head>
<RoutePage appId="timeline">
<div class="timeline-page">
<!-- Header -->
<header class="timeline-header">
<div class="header-left">
<h1 class="header-title">{formatHeaderDate(currentDate)}</h1>
<div class="nav-buttons">
<button onclick={() => (currentDate = subDays(currentDate, 1))} class="nav-btn">
<CaretLeft size={18} />
</button>
<button onclick={() => (currentDate = new Date())} class="today-btn">Heute</button>
<button onclick={() => (currentDate = addDays(currentDate, 1))} class="nav-btn">
<CaretRight size={18} />
</button>
</div>
</div>
<div class="header-right">
{#if totalSeconds > 0}
<span class="total-duration">{formatDuration(totalSeconds)} erfasst</span>
{/if}
<button
class="filter-btn"
class:active={visibleTypes.size < 6}
onclick={() => (showFilters = !showFilters)}
>
<Funnel size={16} />
</button>
</div>
</header>
{#if showFilters}
<div class="filter-bar">
{#each typeConfig as cfg}
{@const active = visibleTypes.has(cfg.type)}
{@const Icon = cfg.icon}
<button class="filter-chip" class:active onclick={() => toggleType(cfg.type)}>
<Icon size={14} />
{cfg.label}
</button>
{/each}
</div>
{/if}
<!-- Timeline content -->
<div class="timeline-content">
{#if blocks.length === 0}
<div class="empty">
<Clock size={48} class="empty-icon" />
<p>{isToday(currentDate) ? 'Noch nichts heute' : 'Keine Einträge an diesem Tag'}</p>
</div>
{:else}
<div class="timeline-list">
{#each blocks as block, i (block.id)}
{@const duration = getBlockDuration(block)}
{@const habitIcon =
block.type === 'habit' && block.icon ? getIconComponent(block.icon) : null}
{@const typeCfg = typeConfig.find((c) => c.type === block.type)}
<div class="timeline-item" class:live={block.isLive}>
<!-- Time column -->
<div class="time-col">
<span class="time-label">{format(new Date(block.startDate), 'HH:mm')}</span>
</div>
<!-- Dot + line -->
<div class="dot-col">
<div
class="dot"
class:live={block.isLive}
style="background: {block.color || getTypeColor(block.type)}"
></div>
{#if i < blocks.length - 1}
<div class="connector-line"></div>
{/if}
</div>
<!-- Content -->
<div class="content-col">
<div class="item-header">
{#if habitIcon}
{@const HabitIcon = habitIcon}
<HabitIcon size={16} style="color: {block.color || '#6b7280'}" />
{:else if typeCfg}
{@const TypeIcon = typeCfg.icon}
<TypeIcon size={16} class="item-type-icon" />
{/if}
<span class="item-title">{block.title}</span>
{#if block.linkedBlockId}
<span class="linked-badge">erledigt</span>
{/if}
{#if block.isLive}
<span class="live-badge">live</span>
{/if}
</div>
<div class="item-meta">
<span>{formatBlockTime(block)}</span>
{#if duration > 0}
<span class="duration-pill">{formatDuration(duration)}</span>
{/if}
</div>
{#if block.description}
<p class="item-description">{block.description}</p>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<ListView />
</RoutePage>
<style>
.timeline-page {
display: flex;
flex-direction: column;
height: 100%;
background: hsl(var(--color-background));
}
.timeline-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid hsl(var(--color-border));
flex-wrap: wrap;
gap: 0.5rem;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.header-title {
font-size: 1.25rem;
font-weight: 700;
color: hsl(var(--color-foreground));
margin: 0;
}
.nav-buttons {
display: flex;
align-items: center;
gap: 0.25rem;
}
.nav-btn {
padding: 0.375rem;
border: none;
background: transparent;
border-radius: var(--radius-md, 8px);
cursor: pointer;
color: hsl(var(--color-muted-foreground));
}
.nav-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.today-btn {
padding: 0.25rem 0.75rem;
border: none;
background: transparent;
border-radius: var(--radius-md, 8px);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.today-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.header-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.total-duration {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.filter-btn {
padding: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
border-radius: var(--radius-md, 8px);
cursor: pointer;
color: hsl(var(--color-muted-foreground));
}
.filter-btn:hover {
background: hsl(var(--color-muted));
}
.filter-btn.active {
background: hsl(var(--color-primary) / 0.1);
border-color: hsl(var(--color-primary) / 0.3);
color: hsl(var(--color-primary));
}
.filter-bar {
display: flex;
gap: 0.375rem;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.filter-chip {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 9999px;
background: transparent;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
}
.filter-chip:hover {
background: hsl(var(--color-muted));
}
.filter-chip.active {
background: hsl(var(--color-primary) / 0.1);
border-color: hsl(var(--color-primary) / 0.3);
color: hsl(var(--color-primary));
}
/* Timeline content */
.timeline-content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 4rem 0;
color: hsl(var(--color-muted-foreground));
}
.empty p {
font-size: 0.875rem;
}
.timeline-list {
display: flex;
flex-direction: column;
}
.timeline-item {
display: flex;
gap: 0;
min-height: 3.5rem;
}
.timeline-item.live {
background: hsl(var(--color-primary) / 0.03);
border-radius: var(--radius-md, 8px);
}
/* Time column */
.time-col {
width: 3.5rem;
flex-shrink: 0;
padding-top: 0.75rem;
text-align: right;
padding-right: 0.75rem;
}
.time-label {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
/* Dot + connector */
.dot-col {
width: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-top: 0.85rem;
flex-shrink: 0;
z-index: 1;
}
.dot.live {
animation: pulse-dot 2s ease-in-out infinite;
}
@keyframes pulse-dot {
0%,
100% {
box-shadow: 0 0 0 0 currentColor;
}
50% {
box-shadow: 0 0 0 4px currentColor;
opacity: 0.3;
}
}
.connector-line {
width: 2px;
flex: 1;
background: hsl(var(--color-border));
margin-top: 0.25rem;
}
/* Content */
.content-col {
flex: 1;
padding: 0.5rem 0 1rem 0.5rem;
min-width: 0;
}
.item-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.item-title {
font-size: 0.9375rem;
font-weight: 500;
color: hsl(var(--color-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.linked-badge {
font-size: 0.625rem;
font-weight: 600;
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
background: hsl(var(--color-success, 142 71% 45%) / 0.15);
color: hsl(var(--color-success, 142 71% 45%));
flex-shrink: 0;
}
.live-badge {
font-size: 0.625rem;
font-weight: 600;
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
animation: pulse-badge 2s ease-in-out infinite;
flex-shrink: 0;
}
@keyframes pulse-badge {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.item-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.125rem;
}
.duration-pill {
padding: 0 0.375rem;
border-radius: 9999px;
background: hsl(var(--color-muted));
font-size: 0.6875rem;
}
.item-description {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.4;
}
</style>