From c01eccb852feaf6495934fed237ca19314c185cf Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 3 Apr 2026 14:01:27 +0200 Subject: [PATCH] refactor(manacore/web): merge entity + app registries into unified AppDescriptor Replace the dual registry system (app-registry.ts + entities/) with a single AppDescriptor that contains identity, views, and entity fields. - Create $lib/app-registry/ with types.ts, registry.ts, apps.ts, index.ts - Merge all 27 app entries + 3 entity descriptors into one registry - Move ViewProps from nav-stack.ts into app-registry/types.ts - Update all 39 consumer files (ListViews, DetailViews, AppPage, etc.) - Delete old files: entities/, app-registry.ts, nav-stack.ts, entity.ts Single source of truth: one AppDescriptor per module, one registry. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/app-registry/apps.ts | 432 ++++++++++++++++++ .../apps/web/src/lib/app-registry/index.ts | 15 + .../{entities => app-registry}/registry.ts | 40 +- .../apps/web/src/lib/app-registry/types.ts | 55 +++ .../lib/components/links/LinkedItems.svelte | 6 +- .../lib/components/workbench/AppPage.svelte | 45 +- .../components/workbench/AppPagePicker.svelte | 4 +- .../lib/components/workbench/app-registry.ts | 238 ---------- .../src/lib/components/workbench/nav-stack.ts | 20 - .../apps/web/src/lib/entities/index.ts | 23 - .../apps/web/src/lib/entities/types.ts | 43 -- .../src/lib/modules/calendar/ListView.svelte | 2 +- .../web/src/lib/modules/calendar/entity.ts | 70 --- .../modules/calendar/views/DetailView.svelte | 2 +- .../web/src/lib/modules/cards/ListView.svelte | 2 +- .../lib/modules/cards/views/DetailView.svelte | 2 +- .../lib/modules/citycorners/ListView.svelte | 2 +- .../citycorners/views/DetailView.svelte | 2 +- .../src/lib/modules/contacts/ListView.svelte | 2 +- .../web/src/lib/modules/contacts/entity.ts | 21 - .../modules/contacts/views/DetailView.svelte | 2 +- .../web/src/lib/modules/finance/entity.ts | 28 -- .../src/lib/modules/habits/ListView.svelte | 2 +- .../apps/web/src/lib/modules/habits/entity.ts | 36 -- .../src/lib/modules/inventar/ListView.svelte | 2 +- .../modules/inventar/views/DetailView.svelte | 2 +- .../src/lib/modules/memoro/ListView.svelte | 2 +- .../modules/memoro/views/DetailView.svelte | 2 +- .../web/src/lib/modules/mukke/ListView.svelte | 2 +- .../lib/modules/mukke/views/DetailView.svelte | 2 +- .../apps/web/src/lib/modules/notes/entity.ts | 38 -- .../src/lib/modules/planta/ListView.svelte | 2 +- .../modules/planta/views/DetailView.svelte | 2 +- .../web/src/lib/modules/presi/ListView.svelte | 2 +- .../lib/modules/presi/views/DetailView.svelte | 2 +- .../src/lib/modules/questions/ListView.svelte | 2 +- .../modules/questions/views/DetailView.svelte | 2 +- .../src/lib/modules/skilltree/ListView.svelte | 2 +- .../modules/skilltree/views/DetailView.svelte | 2 +- .../src/lib/modules/storage/ListView.svelte | 2 +- .../modules/storage/views/DetailView.svelte | 2 +- .../web/src/lib/modules/times/ListView.svelte | 2 +- .../lib/modules/times/views/DetailView.svelte | 2 +- .../web/src/lib/modules/todo/ListView.svelte | 2 +- .../apps/web/src/lib/modules/todo/entity.ts | 38 -- .../lib/modules/todo/views/DetailView.svelte | 2 +- .../web/src/lib/modules/uload/ListView.svelte | 2 +- .../lib/modules/uload/views/DetailView.svelte | 2 +- .../src/lib/modules/zitare/ListView.svelte | 2 +- .../modules/zitare/views/DetailView.svelte | 2 +- .../apps/web/src/lib/splitscreen/registry.ts | 98 ++-- 51 files changed, 617 insertions(+), 699 deletions(-) create mode 100644 apps/manacore/apps/web/src/lib/app-registry/apps.ts create mode 100644 apps/manacore/apps/web/src/lib/app-registry/index.ts rename apps/manacore/apps/web/src/lib/{entities => app-registry}/registry.ts (57%) create mode 100644 apps/manacore/apps/web/src/lib/app-registry/types.ts delete mode 100644 apps/manacore/apps/web/src/lib/components/workbench/app-registry.ts delete mode 100644 apps/manacore/apps/web/src/lib/components/workbench/nav-stack.ts delete mode 100644 apps/manacore/apps/web/src/lib/entities/index.ts delete mode 100644 apps/manacore/apps/web/src/lib/entities/types.ts delete mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/entity.ts delete mode 100644 apps/manacore/apps/web/src/lib/modules/contacts/entity.ts delete mode 100644 apps/manacore/apps/web/src/lib/modules/finance/entity.ts delete mode 100644 apps/manacore/apps/web/src/lib/modules/habits/entity.ts delete mode 100644 apps/manacore/apps/web/src/lib/modules/notes/entity.ts delete mode 100644 apps/manacore/apps/web/src/lib/modules/todo/entity.ts diff --git a/apps/manacore/apps/web/src/lib/app-registry/apps.ts b/apps/manacore/apps/web/src/lib/app-registry/apps.ts new file mode 100644 index 000000000..61acf8312 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/app-registry/apps.ts @@ -0,0 +1,432 @@ +/** + * Unified App Registrations — All app descriptors in one file. + * + * Apps with entity capabilities (todo, calendar, contacts) include + * collection, paramKey, dragType, etc. for cross-module DnD and linking. + * All other apps only declare identity + views. + */ + +import { registerApp } from './registry'; + +// ── Apps with entity capabilities ─────────────────────────── + +registerApp({ + id: 'todo', + name: 'Todo', + color: '#8B5CF6', + views: { + list: { load: () => import('$lib/modules/todo/ListView.svelte') }, + detail: { load: () => import('$lib/modules/todo/views/DetailView.svelte') }, + }, + collection: 'tasks', + paramKey: 'taskId', + dragType: 'task', + acceptsDropFrom: ['event', 'contact'], + transformIncoming: { + event: (source) => ({ + title: source.title as string, + dueDate: source.startDate as string, + description: source.description as string | undefined, + }), + contact: (source) => ({ + title: `Kontaktieren: ${[source.firstName, source.lastName].filter(Boolean).join(' ')}`, + }), + }, + getDisplayData: (item) => ({ + title: (item.title as string) || 'Aufgabe', + subtitle: item.dueDate ? new Date(item.dueDate as string).toLocaleDateString('de') : undefined, + }), + createItem: async (data) => { + const { tasksStore } = await import('$lib/modules/todo/stores/tasks.svelte'); + const task = await tasksStore.createTask( + data as { title: string; dueDate?: string; description?: string } + ); + return task.id; + }, +}); + +registerApp({ + id: 'calendar', + name: 'Kalender', + color: '#3B82F6', + views: { + list: { load: () => import('$lib/modules/calendar/ListView.svelte') }, + detail: { load: () => import('$lib/modules/calendar/views/DetailView.svelte') }, + }, + collection: 'events', + paramKey: 'eventId', + dragType: 'event', + acceptsDropFrom: ['task', 'contact'], + transformIncoming: { + task: (source) => { + const dueDate = (source.dueDate as string) || new Date().toISOString(); + const start = new Date(dueDate); + const end = new Date(start.getTime() + 60 * 60 * 1000); + return { + title: source.title as string, + startTime: start.toISOString(), + endTime: end.toISOString(), + description: source.description as string | undefined, + }; + }, + contact: (source) => { + const name = [source.firstName, source.lastName].filter(Boolean).join(' '); + const now = new Date(); + const end = new Date(now.getTime() + 60 * 60 * 1000); + return { + title: `Treffen mit ${name}`, + startTime: now.toISOString(), + endTime: end.toISOString(), + }; + }, + }, + getDisplayData: (item) => ({ + title: (item.title as string) || 'Termin', + subtitle: item.startDate + ? new Date(item.startDate as string).toLocaleDateString('de', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + : undefined, + }), + createItem: async (data) => { + const { db } = await import('$lib/data/database'); + const { eventsStore } = await import('$lib/modules/calendar/stores/events.svelte'); + + const calendars = await db.table('calendars').toArray(); + const defaultCal = calendars.find((c: Record) => !c.deletedAt); + const calendarId = (defaultCal?.id as string) ?? 'default'; + + const result = await eventsStore.createEvent({ + calendarId, + title: data.title as string, + startTime: data.startTime as string, + endTime: data.endTime as string, + description: (data.description as string) ?? undefined, + }); + + if (!result.success || !result.data) throw new Error(result.error || 'Failed to create event'); + return result.data.id; + }, +}); + +registerApp({ + id: 'contacts', + name: 'Kontakte', + color: '#22C55E', + views: { + list: { load: () => import('$lib/modules/contacts/ListView.svelte') }, + detail: { load: () => import('$lib/modules/contacts/views/DetailView.svelte') }, + }, + collection: 'contacts', + paramKey: 'contactId', + dragType: 'contact', + getDisplayData: (item) => { + const name = [item.firstName, item.lastName].filter(Boolean).join(' '); + return { + title: name || (item.email as string) || 'Kontakt', + subtitle: (item.company as string) ?? undefined, + }; + }, + // Contacts are drag sources only -- dropping onto contacts doesn't create a new contact +}); + +// ── Apps without entity capabilities ──────────────────────── + +registerApp({ + id: 'habits', + name: 'Habits', + color: '#8B5CF6', + views: { + list: { load: () => import('$lib/modules/habits/ListView.svelte') }, + }, + collection: 'habits', + paramKey: 'habitId', + dragType: 'habit', + acceptsDropFrom: ['task'], + transformIncoming: { + task: (source) => ({ + title: source.title as string, + emoji: '\u{1F4AA}', + color: '#6366f1', + }), + }, + getDisplayData: (item) => ({ + title: `${item.emoji as string} ${item.title as string}`, + subtitle: undefined, + }), + createItem: async (data) => { + const { habitsStore } = await import('$lib/modules/habits/stores/habits.svelte'); + const habit = await habitsStore.createHabit({ + title: data.title as string, + emoji: (data.emoji as string) ?? '\u{2B50}', + color: (data.color as string) ?? '#6366f1', + }); + return habit.id; + }, +}); + +registerApp({ + id: 'notes', + name: 'Notes', + color: '#F59E0B', + views: { + list: { load: () => import('$lib/modules/notes/ListView.svelte') }, + }, + collection: 'notes', + paramKey: 'noteId', + dragType: 'note', + acceptsDropFrom: ['task', 'contact'], + transformIncoming: { + task: (source) => ({ + title: source.title as string, + content: (source.description as string) ?? '', + }), + contact: (source) => ({ + title: `${[source.firstName, source.lastName].filter(Boolean).join(' ')}`, + content: `Kontakt: ${[source.firstName, source.lastName].filter(Boolean).join(' ')}`, + }), + }, + getDisplayData: (item) => ({ + title: (item.title as string) || 'Notiz', + subtitle: undefined, + }), + createItem: async (data) => { + const { notesStore } = await import('$lib/modules/notes/stores/notes.svelte'); + const note = await notesStore.createNote({ + title: data.title as string, + content: (data.content as string) ?? '', + }); + return note.id; + }, +}); + +registerApp({ + id: 'finance', + name: 'Finance', + color: '#22C55E', + views: { + list: { load: () => import('$lib/modules/finance/ListView.svelte') }, + }, + collection: 'transactions', + paramKey: 'transactionId', + dragType: 'transaction', + acceptsDropFrom: [], + getDisplayData: (item) => ({ + title: (item.description as string) || 'Transaktion', + subtitle: item.amount ? `${item.type === 'income' ? '+' : '-'}${item.amount}` : undefined, + }), + createItem: async (data) => { + const { financeStore } = await import('$lib/modules/finance/stores/finance.svelte'); + const tx = await financeStore.addTransaction({ + type: 'expense', + amount: (data.amount as number) ?? 0, + description: (data.title as string) ?? (data.description as string) ?? '', + }); + return tx.id; + }, +}); + +registerApp({ + id: 'chat', + name: 'Chat', + color: '#6366F1', + views: { + list: { load: () => import('$lib/modules/chat/ListView.svelte') }, + }, +}); + +registerApp({ + id: 'context', + name: 'Context', + color: '#7C3AED', + views: { + list: { load: () => import('$lib/modules/context/ListView.svelte') }, + }, +}); + +registerApp({ + id: 'times', + name: 'Times', + color: '#F59E0B', + views: { + list: { load: () => import('$lib/modules/times/ListView.svelte') }, + detail: { load: () => import('$lib/modules/times/views/DetailView.svelte') }, + }, +}); + +registerApp({ + id: 'zitare', + name: 'Zitare', + color: '#EC4899', + views: { + list: { load: () => import('$lib/modules/zitare/ListView.svelte') }, + detail: { load: () => import('$lib/modules/zitare/views/DetailView.svelte') }, + }, +}); + +registerApp({ + id: 'cards', + name: 'Cards', + color: '#EF4444', + views: { + list: { load: () => import('$lib/modules/cards/ListView.svelte') }, + detail: { load: () => import('$lib/modules/cards/views/DetailView.svelte') }, + }, +}); + +registerApp({ + id: 'picture', + name: 'Picture', + color: '#8B5CF6', + views: { + list: { load: () => import('$lib/modules/picture/ListView.svelte') }, + }, +}); + +registerApp({ + id: 'mukke', + name: 'Mukke', + color: '#F97316', + views: { + list: { load: () => import('$lib/modules/mukke/ListView.svelte') }, + detail: { load: () => import('$lib/modules/mukke/views/DetailView.svelte') }, + }, +}); + +registerApp({ + id: 'photos', + name: 'Photos', + color: '#06B6D4', + views: { + list: { load: () => import('$lib/modules/photos/ListView.svelte') }, + }, +}); + +registerApp({ + id: 'storage', + name: 'Storage', + color: '#6B7280', + views: { + list: { load: () => import('$lib/modules/storage/ListView.svelte') }, + detail: { load: () => import('$lib/modules/storage/views/DetailView.svelte') }, + }, +}); + +registerApp({ + id: 'nutriphi', + name: 'Nutriphi', + color: '#22C55E', + views: { + list: { load: () => import('$lib/modules/nutriphi/ListView.svelte') }, + }, +}); + +registerApp({ + id: 'planta', + name: 'Planta', + color: '#16A34A', + views: { + list: { load: () => import('$lib/modules/planta/ListView.svelte') }, + detail: { load: () => import('$lib/modules/planta/views/DetailView.svelte') }, + }, +}); + +registerApp({ + id: 'presi', + name: 'Presi', + color: '#A855F7', + views: { + list: { load: () => import('$lib/modules/presi/ListView.svelte') }, + detail: { load: () => import('$lib/modules/presi/views/DetailView.svelte') }, + }, +}); + +registerApp({ + id: 'inventar', + name: 'Inventar', + color: '#78716C', + views: { + list: { load: () => import('$lib/modules/inventar/ListView.svelte') }, + detail: { load: () => import('$lib/modules/inventar/views/DetailView.svelte') }, + }, +}); + +registerApp({ + id: 'memoro', + name: 'Memoro', + color: '#F59E0B', + views: { + list: { load: () => import('$lib/modules/memoro/ListView.svelte') }, + detail: { load: () => import('$lib/modules/memoro/views/DetailView.svelte') }, + }, +}); + +registerApp({ + id: 'questions', + name: 'Questions', + color: '#2563EB', + views: { + list: { load: () => import('$lib/modules/questions/ListView.svelte') }, + detail: { load: () => import('$lib/modules/questions/views/DetailView.svelte') }, + }, +}); + +registerApp({ + id: 'skilltree', + name: 'SkillTree', + color: '#D946EF', + views: { + list: { load: () => import('$lib/modules/skilltree/ListView.svelte') }, + detail: { load: () => import('$lib/modules/skilltree/views/DetailView.svelte') }, + }, +}); + +registerApp({ + id: 'moodlit', + name: 'Moodlit', + color: '#F97316', + views: { + list: { load: () => import('$lib/modules/moodlit/ListView.svelte') }, + }, +}); + +registerApp({ + id: 'citycorners', + name: 'CityCorners', + color: '#14B8A6', + views: { + list: { load: () => import('$lib/modules/citycorners/ListView.svelte') }, + detail: { load: () => import('$lib/modules/citycorners/views/DetailView.svelte') }, + }, +}); + +registerApp({ + id: 'uload', + name: 'uLoad', + color: '#0EA5E9', + views: { + list: { load: () => import('$lib/modules/uload/ListView.svelte') }, + detail: { load: () => import('$lib/modules/uload/views/DetailView.svelte') }, + }, +}); + +registerApp({ + id: 'calc', + name: 'Calc', + color: '#6B7280', + views: { + list: { load: () => import('$lib/modules/calc/ListView.svelte') }, + }, +}); + +registerApp({ + id: 'playground', + name: 'Playground', + color: '#9CA3AF', + views: { + list: { load: () => import('$lib/modules/playground/ListView.svelte') }, + }, +}); diff --git a/apps/manacore/apps/web/src/lib/app-registry/index.ts b/apps/manacore/apps/web/src/lib/app-registry/index.ts new file mode 100644 index 000000000..21d21808c --- /dev/null +++ b/apps/manacore/apps/web/src/lib/app-registry/index.ts @@ -0,0 +1,15 @@ +// Types +export type { AppDescriptor, ViewLoader, EntityDisplayData, DropResult, ViewProps } from './types'; + +// Registry +export { + registerApp, + getApp, + getAppByDragType, + canDrop, + executeDrop, + getAllApps, +} from './registry'; + +// Register all apps eagerly — descriptors are lightweight with lazy imports +import './apps'; diff --git a/apps/manacore/apps/web/src/lib/entities/registry.ts b/apps/manacore/apps/web/src/lib/app-registry/registry.ts similarity index 57% rename from apps/manacore/apps/web/src/lib/entities/registry.ts rename to apps/manacore/apps/web/src/lib/app-registry/registry.ts index 0157b8e44..5ef567d9c 100644 --- a/apps/manacore/apps/web/src/lib/entities/registry.ts +++ b/apps/manacore/apps/web/src/lib/app-registry/registry.ts @@ -1,30 +1,30 @@ /** - * Entity Registry — Collects module descriptors and orchestrates cross-module drops. + * Unified App Registry — Collects app descriptors and orchestrates cross-module drops. */ import type { DragType } from '@manacore/shared-ui/dnd'; import { linkMutations, buildCachedData } from '@manacore/shared-links'; -import type { EntityDescriptor, DropResult } from './types'; +import type { AppDescriptor, DropResult } from './types'; -const entities = new Map(); +const apps = new Map(); -export function registerEntity(descriptor: EntityDescriptor): void { - entities.set(descriptor.appId, descriptor); +export function registerApp(descriptor: AppDescriptor): void { + apps.set(descriptor.id, descriptor); } -export function getEntity(appId: string): EntityDescriptor | undefined { - return entities.get(appId); +export function getApp(appId: string): AppDescriptor | undefined { + return apps.get(appId); } -export function getEntityByDragType(type: DragType): EntityDescriptor | undefined { - for (const e of entities.values()) { - if (e.dragType === type) return e; +export function getAppByDragType(type: DragType): AppDescriptor | undefined { + for (const a of apps.values()) { + if (a.dragType === type) return a; } return undefined; } export function canDrop(sourceType: DragType, targetAppId: string): boolean { - const target = entities.get(targetAppId); + const target = apps.get(targetAppId); if (!target?.acceptsDropFrom?.includes(sourceType)) return false; if (!target.createItem) return false; if (!target.transformIncoming?.[sourceType]) return false; @@ -36,14 +36,18 @@ export async function executeDrop( sourceAppId: string, targetAppId: string ): Promise { - const source = entities.get(sourceAppId); - const target = entities.get(targetAppId); - if (!source || !target) - throw new Error(`Entity not registered: ${sourceAppId} or ${targetAppId}`); + const source = apps.get(sourceAppId); + const target = apps.get(targetAppId); + if (!source || !target) throw new Error(`App not registered: ${sourceAppId} or ${targetAppId}`); if (!target.createItem) throw new Error(`Target ${targetAppId} has no createItem`); + if (!source.dragType) throw new Error(`Source ${sourceAppId} has no dragType`); + if (!source.collection) throw new Error(`Source ${sourceAppId} has no collection`); + if (!target.collection) throw new Error(`Target ${targetAppId} has no collection`); + if (!source.getDisplayData) throw new Error(`Source ${sourceAppId} has no getDisplayData`); + if (!target.getDisplayData) throw new Error(`Target ${targetAppId} has no getDisplayData`); const transform = target.transformIncoming?.[source.dragType]; - if (!transform) throw new Error(`No transform for ${source.dragType} → ${targetAppId}`); + if (!transform) throw new Error(`No transform for ${source.dragType} -> ${targetAppId}`); // 1. Transform source data into target shape const transformedData = transform(sourceItem); @@ -74,6 +78,6 @@ export async function executeDrop( return { newItemId, linkPairId: forward.pairId }; } -export function getAllEntities(): EntityDescriptor[] { - return Array.from(entities.values()); +export function getAllApps(): AppDescriptor[] { + return Array.from(apps.values()); } diff --git a/apps/manacore/apps/web/src/lib/app-registry/types.ts b/apps/manacore/apps/web/src/lib/app-registry/types.ts new file mode 100644 index 000000000..17e5ace0e --- /dev/null +++ b/apps/manacore/apps/web/src/lib/app-registry/types.ts @@ -0,0 +1,55 @@ +/** + * Unified App Registry — Types + * + * Each app declares an AppDescriptor that describes identity, views, + * and optional entity capabilities (DnD, linking, cross-module drops). + */ + +import type { DragType } from '@manacore/shared-ui/dnd'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyComponent = import('svelte').Component; + +export interface ViewLoader { + load: () => Promise<{ default: AnyComponent }>; +} + +export interface EntityDisplayData { + title: string; + subtitle?: string; +} + +export interface AppDescriptor { + // -- Identity -- + id: string; + name: string; + color: string; + + // -- Views -- + views: { + list: ViewLoader; + detail?: ViewLoader; + }; + + // -- Entity (optional -- for DnD + linking) -- + collection?: string; + paramKey?: string; + dragType?: DragType; + acceptsDropFrom?: DragType[]; + transformIncoming?: Partial< + Record) => Record> + >; + createItem?: (data: Record) => Promise; + getDisplayData?: (item: Record) => EntityDisplayData; +} + +export interface DropResult { + newItemId: string; + linkPairId: string; +} + +export interface ViewProps { + navigate: (viewName: string, params?: Record) => void; + goBack: () => void; + params: Record; +} diff --git a/apps/manacore/apps/web/src/lib/components/links/LinkedItems.svelte b/apps/manacore/apps/web/src/lib/components/links/LinkedItems.svelte index aa4fd2835..4218ad6cd 100644 --- a/apps/manacore/apps/web/src/lib/components/links/LinkedItems.svelte +++ b/apps/manacore/apps/web/src/lib/components/links/LinkedItems.svelte @@ -8,8 +8,8 @@ type ManaRecordRef, type LocalManaLink, } from '@manacore/shared-links'; - import { getAppEntry } from '$lib/components/workbench/app-registry'; - import type { ViewProps } from '$lib/components/workbench/nav-stack'; + import { getApp } from '$lib/app-registry'; + import type { ViewProps } from '$lib/app-registry'; interface Props { recordRef: ManaRecordRef; @@ -34,7 +34,7 @@