feat(articles): bulk-import schema + plan (Phase 1)

Three new sync-tracked Dexie tables under the articles appId:

  articleImportJobs     — job header (counters, status, lease metadata).
  articleImportItems    — one row per URL in a job, state-machine driven.
  articleExtractPickup  — short-lived server→client handoff inbox.

URL stays plaintext on items by necessity — the server-worker reads it
without master-key access, same rationale as articles.originalUrl. The
extracted article eventually lands encrypted in the existing `articles`
table; bulk-import rows hold only pointers.

Plan: docs/plans/articles-bulk-import.md (full architecture, 7 phases,
test matrix, edge-cases). Phase 2 already shipped in 5535f2da4 (worker);
this commit lays the schema underneath it.

Originally committed as b2f4e8314, lost during a parallel reset, here
restored via cherry-pick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-28 22:11:51 +02:00
parent 5535f2da48
commit 7bca16dfa7
59 changed files with 785 additions and 4249 deletions

View file

@ -1,199 +0,0 @@
<script lang="ts">
import { SCENE, LAKES, RIVERS } from './data/layout';
import { createMockEcosystem } from './data/mockData';
import type { AppData } from './data/types';
import Background from './terrain/Background.svelte';
import Terrain from './terrain/Terrain.svelte';
import WaterBody from './terrain/WaterBody.svelte';
import RiverFlow from './water/RiverFlow.svelte';
import PlantFactory from './plants/PlantFactory.svelte';
import Ambient from './atmosphere/Ambient.svelte';
import PlantTooltip from './ui/PlantTooltip.svelte';
import DetailPanel from './ui/DetailPanel.svelte';
import { onMount } from 'svelte';
let apps = $state(createMockEcosystem());
let hour = $state(new Date().getHours() + new Date().getMinutes() / 60);
onMount(() => {
// Update time every minute
const interval = setInterval(() => {
hour = new Date().getHours() + new Date().getMinutes() / 60;
}, 60000);
return () => clearInterval(interval);
});
let selectedApp = $state<AppData | null>(null);
let hoveredApp = $state<AppData | null>(null);
let tooltipPos = $state({ x: 0, y: 0 });
// Pan & zoom state
let svgEl: SVGSVGElement | undefined = $state();
let viewBox = $state({ x: 0, y: 0, w: SCENE.width as number, h: SCENE.height as number });
let isPanning = $state(false);
let hasPanned = $state(false);
let panStart = $state({ x: 0, y: 0 });
let panViewBoxStart = $state({ x: 0, y: 0 });
function handleWheel(e: WheelEvent) {
e.preventDefault();
const scaleFactor = e.deltaY > 0 ? 1.08 : 0.92;
const rect = svgEl?.getBoundingClientRect();
if (!rect) return;
const mx = (e.clientX - rect.left) / rect.width;
const my = (e.clientY - rect.top) / rect.height;
const px = viewBox.x + mx * viewBox.w;
const py = viewBox.y + my * viewBox.h;
const nw = Math.max(400, Math.min(SCENE.width * 2, viewBox.w * scaleFactor));
const nh = Math.max(225, Math.min(SCENE.height * 2, viewBox.h * scaleFactor));
viewBox = { x: px - mx * nw, y: py - my * nh, w: nw, h: nh };
}
function handlePointerDown(e: PointerEvent) {
if (e.button !== 0) return;
isPanning = true;
hasPanned = false;
panStart = { x: e.clientX, y: e.clientY };
panViewBoxStart = { x: viewBox.x, y: viewBox.y };
(e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId);
}
function handlePointerMove(e: PointerEvent) {
if (!isPanning || !svgEl) return;
const rect = svgEl.getBoundingClientRect();
const dx = ((e.clientX - panStart.x) / rect.width) * viewBox.w;
const dy = ((e.clientY - panStart.y) / rect.height) * viewBox.h;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) hasPanned = true;
viewBox = { ...viewBox, x: panViewBoxStart.x - dx, y: panViewBoxStart.y - dy };
}
function handlePointerUp() {
isPanning = false;
}
function handleAppClick(app: AppData) {
if (hasPanned) return;
selectedApp = app;
}
function handleAppHover(app: AppData, e: MouseEvent) {
hoveredApp = app;
tooltipPos = { x: e.clientX, y: e.clientY };
}
function handleAppHoverMove(e: MouseEvent) {
tooltipPos = { x: e.clientX, y: e.clientY };
}
function handleAppLeave() {
hoveredApp = null;
}
function closePanel() {
selectedApp = null;
}
function resetView() {
viewBox = { x: 0, y: 0, w: SCENE.width, h: SCENE.height };
}
let viewBoxStr = $derived(`${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`);
</script>
<div
class="relative overflow-hidden rounded-xl"
style="width: 100%; height: 100%; min-height: 500px; background-color: #e0f2fe;"
>
<!-- Reset zoom button -->
<button
type="button"
onclick={resetView}
class="absolute right-3 top-3 z-10 rounded-lg bg-white/80 px-3 py-1.5 text-xs font-medium text-gray-600 shadow-sm backdrop-blur-sm transition-colors hover:bg-white"
>
Reset View
</button>
<!-- Title overlay -->
<div class="absolute left-4 top-3 z-10">
<h2 class="text-lg font-semibold text-slate-700/80" style="font-family: serif;">
Mana Seenplatte
</h2>
<p class="text-xs text-slate-500/70">Ecosystem Observatory</p>
</div>
<!-- Legend -->
<div
class="absolute bottom-3 left-3 z-10 flex gap-3 rounded-lg bg-white/70 px-3 py-2 text-[10px] text-slate-600 backdrop-blur-sm"
>
<span class="flex items-center gap-1">
<svg width="12" height="12"><circle cx="6" cy="8" r="3" fill="#2D6B30" /></svg>
Mature
</span>
<span class="flex items-center gap-1">
<svg width="12" height="12"><circle cx="6" cy="8" r="3" fill="#3E8B42" /></svg>
Production
</span>
<span class="flex items-center gap-1">
<svg width="12" height="12"><circle cx="6" cy="8" r="3" fill="#7DB86A" /></svg>
Beta
</span>
<span class="flex items-center gap-1">
<svg width="12" height="12"><circle cx="6" cy="8" r="3" fill="#C4B848" /></svg>
Alpha
</span>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<svg
bind:this={svgEl}
viewBox={viewBoxStr}
preserveAspectRatio="xMidYMid meet"
xmlns="http://www.w3.org/2000/svg"
style="width: 100%; height: 100%; display: block; cursor: {isPanning ? 'grabbing' : 'grab'};"
onwheel={handleWheel}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointerleave={handlePointerUp}
>
<!-- Layer 1: Sky + Mountains -->
<Background {hour} />
<!-- Layer 2: Terrain (meadows, shores) -->
<Terrain />
<!-- Layer 3: Rivers (behind lakes) -->
{#each RIVERS as river}
<RiverFlow {river} />
{/each}
<!-- Layer 4: Lakes -->
{#each LAKES as lake}
<WaterBody {lake} />
{/each}
<!-- Layer 5: Ambient creatures -->
<Ambient {hour} />
<!-- Layer 6: Plants (apps) sorted by y-position for depth -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#each apps.toSorted((a, b) => a.position.y - b.position.y) as app (app.id)}
<g
onmouseenter={(e) => handleAppHover(app, e)}
onmousemove={handleAppHoverMove}
onmouseleave={handleAppLeave}
>
<PlantFactory {app} onclick={() => handleAppClick(app)} />
</g>
{/each}
</svg>
<!-- Tooltip (HTML overlay, positioned via mouse coordinates) -->
{#if hoveredApp && !selectedApp}
<PlantTooltip app={hoveredApp} x={tooltipPos.x} y={tooltipPos.y} />
{/if}
</div>
<!-- Detail Panel (outside the SVG container for proper positioning) -->
<DetailPanel app={selectedApp} onclose={closePanel} />

View file

@ -1,126 +0,0 @@
<script lang="ts">
import { SCENE } from '../data/layout';
let { hour = 12 }: { hour?: number } = $props();
let isNight = $derived(hour < 6 || hour > 20.5);
// Bird flight paths (simple arcs)
const birds = [
{ startX: -50, startY: 180, endX: SCENE.width + 50, endY: 150, dur: 25, delay: 0 },
{ startX: -30, startY: 220, endX: SCENE.width + 30, endY: 200, dur: 30, delay: 8 },
{ startX: SCENE.width + 40, startY: 160, endX: -40, endY: 190, dur: 28, delay: 15 },
];
// Firefly positions for night mode
const fireflies = Array.from({ length: 15 }, (_, i) => ({
cx: ((i * 137 + 80) % (SCENE.width - 200)) + 100,
cy: 450 + ((i * 89) % 350),
dur: 3 + (i % 4),
delay: (i % 6) * 0.8,
}));
// Dragonfly paths over lakes
const dragonflies = [
{ cx: 790, cy: 470, rx: 40, ry: 20, dur: 6 },
{ cx: 340, cy: 410, rx: 25, ry: 15, dur: 5 },
{ cx: 760, cy: 650, rx: 35, ry: 18, dur: 7 },
];
</script>
<!-- Birds (daytime only) -->
{#if !isNight}
{#each birds as bird}
<g opacity="0.5">
<!-- Simple V-shape bird -->
<g>
<animateMotion
dur="{bird.dur}s"
begin="{bird.delay}s"
repeatCount="indefinite"
path="M{bird.startX},{bird.startY} Q{SCENE.width / 2},{bird.startY -
40} {bird.endX},{bird.endY}"
/>
<path
d="M-5,0 L0,-2 L5,0"
fill="none"
stroke="#3A4A5A"
stroke-width="1"
stroke-linecap="round"
/>
</g>
</g>
{/each}
<!-- Dragonflies over lakes -->
{#each dragonflies as df}
<g opacity="0.4">
<circle r="1.5" fill="#60A0C0">
<animateMotion
dur="{df.dur}s"
repeatCount="indefinite"
path="M{df.cx},{df.cy} Q{df.cx + df.rx},{df.cy - df.ry} {df.cx},{df.cy -
df.ry * 2} Q{df.cx - df.rx},{df.cy - df.ry} {df.cx},{df.cy}"
/>
<animate
attributeName="opacity"
values="0.3;0.7;0.3"
dur="{df.dur / 2}s"
repeatCount="indefinite"
/>
</circle>
</g>
{/each}
{/if}
<!-- Fireflies (night only) -->
{#if isNight}
{#each fireflies as ff}
<circle cx={ff.cx} cy={ff.cy} r="2" fill="#E8E060">
<animate
attributeName="opacity"
values="0;0.8;0.8;0"
dur="{ff.dur}s"
begin="{ff.delay}s"
repeatCount="indefinite"
/>
<animate
attributeName="cx"
values="{ff.cx};{ff.cx + 8};{ff.cx - 5};{ff.cx}"
dur="{ff.dur * 2}s"
begin="{ff.delay}s"
repeatCount="indefinite"
/>
<animate
attributeName="cy"
values="{ff.cy};{ff.cy - 10};{ff.cy - 5};{ff.cy}"
dur="{ff.dur * 1.5}s"
begin="{ff.delay}s"
repeatCount="indefinite"
/>
</circle>
{/each}
{/if}
<!-- Butterflies near plants (daytime, subtle) -->
{#if !isNight}
<g opacity="0.35">
<g>
<animateMotion
dur="12s"
repeatCount="indefinite"
path="M730,400 Q750,380 780,395 Q800,385 790,405 Q770,410 730,400"
/>
<path d="M0,0 L-3,-3 L0,-1 L3,-3 Z" fill="#D080C0" />
</g>
<g>
<animateMotion
dur="10s"
begin="4s"
repeatCount="indefinite"
path="M350,540 Q370,520 390,535 Q380,550 360,555 Q340,550 350,540"
/>
<path d="M0,0 L-3,-3 L0,-1 L3,-3 Z" fill="#E0A040" />
</g>
</g>
{/if}

View file

@ -1,83 +0,0 @@
<script lang="ts">
import { SCENE } from '../data/layout';
let { hour = new Date().getHours() + new Date().getMinutes() / 60 }: { hour?: number } = $props();
// Interpolate between time-of-day color presets
const presets = [
{ h: 0, top: '#0B1026', bottom: '#1A2444' }, // midnight
{ h: 5, top: '#1A1B3A', bottom: '#2D3458' }, // pre-dawn
{ h: 6.5, top: '#4A3060', bottom: '#E8956A' }, // sunrise
{ h: 8, top: '#6B9FD4', bottom: '#E0E8F0' }, // morning
{ h: 12, top: '#87CEEB', bottom: '#E0F0FF' }, // noon
{ h: 17, top: '#87CEEB', bottom: '#E0F0FF' }, // afternoon
{ h: 19, top: '#C45030', bottom: '#F0B860' }, // sunset
{ h: 20.5, top: '#2C1654', bottom: '#4A3060' }, // dusk
{ h: 22, top: '#0B1026', bottom: '#1A2444' }, // night
{ h: 24, top: '#0B1026', bottom: '#1A2444' }, // midnight wrap
];
function lerp(a: string, b: string, t: number): string {
const ah = parseInt(a.slice(1), 16);
const bh = parseInt(b.slice(1), 16);
const r = Math.round(((ah >> 16) & 0xff) * (1 - t) + ((bh >> 16) & 0xff) * t);
const g = Math.round(((ah >> 8) & 0xff) * (1 - t) + ((bh >> 8) & 0xff) * t);
const bl = Math.round((ah & 0xff) * (1 - t) + (bh & 0xff) * t);
return `#${((r << 16) | (g << 8) | bl).toString(16).padStart(6, '0')}`;
}
function getSkyColors(h: number) {
for (let i = 0; i < presets.length - 1; i++) {
if (h >= presets[i].h && h < presets[i + 1].h) {
const t = (h - presets[i].h) / (presets[i + 1].h - presets[i].h);
return {
top: lerp(presets[i].top, presets[i + 1].top, t),
bottom: lerp(presets[i].bottom, presets[i + 1].bottom, t),
};
}
}
return { top: presets[0].top, bottom: presets[0].bottom };
}
let colors = $derived(getSkyColors(hour));
let isNight = $derived(hour < 6 || hour > 20.5);
let isDusk = $derived(hour > 18.5 && hour < 20.5);
let gradientId = 'sky-gradient-dynamic';
</script>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color={colors.top} />
<stop offset="100%" stop-color={colors.bottom} />
</linearGradient>
</defs>
<rect x="0" y="0" width={SCENE.width} height="360" fill="url(#{gradientId})" />
<!-- Stars (visible at night) -->
{#if isNight}
<g opacity={hour < 5 ? 0.7 : 0.4}>
{#each Array(35) as _, i}
{@const sx = (i * 137 + 42) % SCENE.width}
{@const sy = (i * 89 + 17) % 250}
{@const sr = 0.5 + (i % 3) * 0.4}
<circle cx={sx} cy={sy} r={sr} fill="white">
<animate
attributeName="opacity"
values="0.3;0.9;0.3"
dur="{2 + (i % 4)}s"
begin="{(i % 7) * 0.5}s"
repeatCount="indefinite"
/>
</circle>
{/each}
</g>
{/if}
<!-- Sun glow (during golden hours) -->
{#if isDusk}
<circle cx="1400" cy="260" r="80" fill="#F0A040" opacity="0.15">
<animate attributeName="opacity" values="0.1;0.2;0.1" dur="4s" repeatCount="indefinite" />
</circle>
<circle cx="1400" cy="260" r="30" fill="#F0C860" opacity="0.3" />
{/if}

View file

@ -1,84 +0,0 @@
// Natural color palette for the Seenplatte ecosystem
export const sky = {
dayTop: '#87CEEB',
dayBottom: '#E0F0FF',
duskTop: '#2C1654',
duskBottom: '#E8956A',
nightTop: '#0B1026',
nightBottom: '#1A2444',
} as const;
export const mountains = {
far: '#8BA4B8',
mid: '#6B8A9E',
near: '#4A7085',
snow: '#E8F0F4',
} as const;
export const water = {
shallow: '#7EC8D9',
mid: '#4BA3B5',
deep: '#2A7A8C',
veryDeep: '#1A5666',
river: '#5BB5C5',
highlight: '#A8E4F0',
foam: '#E8F6FA',
} as const;
export const terrain = {
meadow: '#7DB86A',
meadowLight: '#96CC86',
meadowDark: '#5E9A4D',
shore: '#C4B48A',
shoreDark: '#A89870',
path: '#D4C4A0',
rock: '#8C8C80',
rockLight: '#A8A89C',
} as const;
export const vegetation = {
// Tree health colors based on ManaScore
healthyDark: '#2D6B30',
healthy: '#3E8B42',
healthyLight: '#5AAB5E',
moderate: '#7DB86A',
warning: '#C4B848',
stressed: '#D4944A',
critical: '#B85C4A',
// Specific plant colors
trunk: '#6B4E3D',
trunkLight: '#8B6E5D',
reed: '#7DA868',
reedDark: '#5A8548',
lilyPad: '#4A8B50',
lilyFlower: '#E8B0D0',
lilyFlowerCenter: '#F0D060',
moss: '#5A8B4A',
mossLight: '#78A868',
sprout: '#90C880',
sproutStake: '#A89070',
} as const;
/**
* Get tree crown color based on ManaScore (0-100)
*/
export function getHealthColor(score: number): string {
if (score >= 85) return vegetation.healthyDark;
if (score >= 70) return vegetation.healthy;
if (score >= 55) return vegetation.moderate;
if (score >= 40) return vegetation.warning;
if (score >= 25) return vegetation.stressed;
return vegetation.critical;
}
/**
* Get water clarity based on error rate (0 = clear, 1 = murky)
*/
export function getWaterColor(clarity: number): string {
if (clarity > 0.8) return water.shallow;
if (clarity > 0.5) return water.mid;
if (clarity > 0.2) return water.deep;
return water.veryDeep;
}

View file

@ -1,171 +0,0 @@
import type { LakeData, RiverData } from './types';
// Scene dimensions (SVG viewBox)
export const SCENE = {
width: 1600,
height: 900,
viewBox: '0 0 1600 900',
} as const;
// Mountain layer paths (3 layers, back to front)
export const MOUNTAIN_PATHS = {
far: 'M0,280 Q200,180 400,240 Q500,210 600,250 Q750,160 900,220 Q1050,180 1200,230 Q1350,170 1500,210 L1600,240 L1600,350 L0,350 Z',
mid: 'M0,310 Q150,250 300,290 Q450,240 550,280 Q700,220 850,270 Q1000,230 1150,275 Q1300,240 1450,265 L1600,280 L1600,380 L0,380 Z',
near: 'M0,340 Q100,300 250,330 Q400,290 500,320 Q650,280 800,315 Q950,285 1100,320 Q1250,295 1400,310 L1600,320 L1600,400 L0,400 Z',
} as const;
// Lake definitions with organic SVG paths
export const LAKES: LakeData[] = [
{
id: 'auth',
name: 'auth',
label: 'Zentralsee',
path: 'M680,440 Q720,410 790,415 Q860,420 890,445 Q910,470 900,500 Q885,535 850,550 Q800,565 750,558 Q700,550 680,525 Q660,500 665,470 Q668,450 680,440 Z',
color: '#4BA3B5',
colorDeep: '#2A7A8C',
clarity: 1,
level: 0.8,
position: { x: 790, y: 485 },
},
{
id: 'redis',
name: 'redis',
label: 'Bergsee',
path: 'M280,390 Q310,370 350,375 Q390,380 405,400 Q415,420 405,440 Q390,455 355,460 Q320,462 295,450 Q275,435 270,415 Q268,398 280,390 Z',
color: '#7EC8D9',
colorDeep: '#4BA3B5',
clarity: 1,
level: 0.9,
position: { x: 340, y: 420 },
},
{
id: 'minio',
name: 'minio',
label: 'Stausee',
path: 'M1180,400 Q1220,375 1280,380 Q1340,385 1370,410 Q1390,435 1380,465 Q1360,490 1320,500 Q1270,510 1220,502 Q1185,492 1170,468 Q1158,445 1165,420 Q1170,405 1180,400 Z',
color: '#5BB5C5',
colorDeep: '#3A8A9A',
clarity: 0.9,
level: 0.7,
position: { x: 1275, y: 440 },
},
{
id: 'db-left',
name: 'postgres-left',
label: 'Waldsee West',
path: 'M180,580 Q220,555 280,560 Q340,565 365,590 Q380,615 370,640 Q355,665 310,675 Q260,682 215,670 Q180,658 165,635 Q155,612 165,595 Q170,582 180,580 Z',
color: '#3A8A9A',
colorDeep: '#1A5666',
clarity: 0.85,
level: 0.75,
position: { x: 270, y: 620 },
},
{
id: 'db-center',
name: 'postgres-center',
label: 'Waldsee Mitte',
path: 'M650,620 Q700,595 770,600 Q840,605 870,635 Q890,660 878,690 Q862,718 810,730 Q760,738 710,730 Q660,720 640,695 Q625,670 632,645 Q638,628 650,620 Z',
color: '#3A8A9A',
colorDeep: '#1A5666',
clarity: 0.9,
level: 0.8,
position: { x: 760, y: 665 },
},
{
id: 'db-right',
name: 'postgres-right',
label: 'Waldsee Ost',
path: 'M1120,590 Q1160,568 1220,572 Q1280,578 1305,600 Q1322,625 1315,652 Q1300,678 1255,690 Q1210,698 1165,688 Q1130,678 1115,655 Q1102,632 1108,610 Q1112,595 1120,590 Z',
color: '#3A8A9A',
colorDeep: '#1A5666',
clarity: 0.85,
level: 0.75,
position: { x: 1215, y: 630 },
},
];
// Rivers connecting the lakes
export const RIVERS: RiverData[] = [
{
id: 'redis-to-auth',
from: 'redis',
to: 'auth',
path: 'M405,430 Q480,435 540,440 Q600,445 665,460',
flowSpeed: 0.8,
width: 8,
},
{
id: 'minio-to-auth',
from: 'minio',
to: 'auth',
path: 'M1170,450 Q1100,455 1020,460 Q940,465 900,475',
flowSpeed: 0.6,
width: 8,
},
{
id: 'auth-to-db-left',
from: 'auth',
to: 'db-left',
path: 'M710,555 Q620,570 520,580 Q420,590 365,595',
flowSpeed: 0.7,
width: 10,
},
{
id: 'auth-to-db-center',
from: 'auth',
to: 'db-center',
path: 'M790,560 Q785,580 778,600 Q770,610 765,620',
flowSpeed: 0.7,
width: 10,
},
{
id: 'auth-to-db-right',
from: 'auth',
to: 'db-right',
path: 'M870,548 Q960,565 1040,575 Q1080,582 1115,592',
flowSpeed: 0.7,
width: 10,
},
{
id: 'inlet',
from: 'source',
to: 'auth',
path: 'M790,350 Q792,370 790,390 Q788,410 790,420',
flowSpeed: 0.9,
width: 6,
},
];
// App positions around the lakes
export const APP_POSITIONS: Record<string, { x: number; y: number; lakeId: string }> = {
// Around Zentralsee (auth) - core/mature apps
mana: { x: 730, y: 410, lakeId: 'auth' },
chat: { x: 860, y: 420, lakeId: 'auth' },
picture: { x: 910, y: 480, lakeId: 'auth' },
presi: { x: 660, y: 490, lakeId: 'auth' },
// Around Waldsee West (db-left)
calendar: { x: 170, y: 560, lakeId: 'db-left' },
todo: { x: 330, y: 555, lakeId: 'db-left' },
contacts: { x: 370, y: 630, lakeId: 'db-left' },
storage: { x: 160, y: 650, lakeId: 'db-left' },
// Around Waldsee Mitte (db-center)
quotes: { x: 640, y: 600, lakeId: 'db-center' },
music: { x: 850, y: 610, lakeId: 'db-center' },
clock: { x: 880, y: 680, lakeId: 'db-center' },
food: { x: 650, y: 720, lakeId: 'db-center' },
// Around Waldsee Ost (db-right)
photos: { x: 1110, y: 575, lakeId: 'db-right' },
skilltree: { x: 1310, y: 590, lakeId: 'db-right' },
context: { x: 1320, y: 660, lakeId: 'db-right' },
plants: { x: 1115, y: 675, lakeId: 'db-right' },
// Around Bergsee (redis) - lightweight/cache
traces: { x: 400, y: 385, lakeId: 'redis' },
// Around Stausee (minio) - storage-heavy
cards: { x: 1180, y: 385, lakeId: 'minio' },
questions: { x: 1370, y: 400, lakeId: 'minio' },
};

View file

@ -1,352 +0,0 @@
import type { AppData, AppStatus, PlantType, CategoryScores } from './types';
import { APP_POSITIONS } from './layout';
interface AppDefinition {
id: string;
displayName: string;
score: number;
status: AppStatus;
categories: CategoryScores;
previousScore?: number;
}
function getPlantType(status: AppStatus, score: number): PlantType {
if (score >= 85) return 'oak';
if (score >= 70) return 'birch';
if (status === 'beta' && score >= 55) return 'youngTree';
if (status === 'beta') return 'reed';
if (status === 'alpha') return 'sprout';
if (status === 'prototype') return 'sprout';
return 'youngTree';
}
// Real ManaScore data from 2026-03-24 audits
const APP_DEFINITIONS: AppDefinition[] = [
{
id: 'calendar',
displayName: 'Calendar',
score: 97,
previousScore: 82,
status: 'mature',
categories: {
backend: 95,
frontend: 96,
database: 92,
testing: 90,
deployment: 92,
documentation: 98,
security: 92,
ux: 95,
},
},
{
id: 'todo',
displayName: 'Todo',
score: 96,
previousScore: 80,
status: 'mature',
categories: {
backend: 94,
frontend: 95,
database: 88,
testing: 90,
deployment: 92,
documentation: 95,
security: 90,
ux: 94,
},
},
{
id: 'contacts',
displayName: 'Contacts',
score: 94,
status: 'production',
categories: {
backend: 92,
frontend: 90,
database: 88,
testing: 88,
deployment: 90,
documentation: 92,
security: 85,
ux: 85,
},
},
{
id: 'mana',
displayName: 'Mana',
score: 88,
status: 'production',
categories: {
backend: 55,
frontend: 90,
database: 70,
testing: 72,
deployment: 90,
documentation: 88,
security: 80,
ux: 92,
},
},
{
id: 'presi',
displayName: 'Presi',
score: 86,
status: 'mature',
categories: {
backend: 90,
frontend: 82,
database: 85,
testing: 82,
deployment: 75,
documentation: 90,
security: 85,
ux: 82,
},
},
{
id: 'storage',
displayName: 'Storage',
score: 84,
previousScore: 55,
status: 'production',
categories: {
backend: 88,
frontend: 84,
database: 82,
testing: 78,
deployment: 65,
documentation: 78,
security: 78,
ux: 75,
},
},
{
id: 'chat',
displayName: 'Chat',
score: 82,
status: 'production',
categories: {
backend: 90,
frontend: 82,
database: 95,
testing: 60,
deployment: 92,
documentation: 85,
security: 82,
ux: 80,
},
},
{
id: 'picture',
displayName: 'Picture',
score: 81,
status: 'production',
categories: {
backend: 90,
frontend: 80,
database: 92,
testing: 55,
deployment: 75,
documentation: 78,
security: 80,
ux: 78,
},
},
{
id: 'music',
displayName: 'Music',
score: 80,
status: 'beta',
categories: {
backend: 90,
frontend: 78,
database: 90,
testing: 65,
deployment: 85,
documentation: 80,
security: 78,
ux: 60,
},
},
{
id: 'food',
displayName: 'Food',
score: 63,
status: 'beta',
categories: {
backend: 78,
frontend: 62,
database: 80,
testing: 58,
deployment: 40,
documentation: 85,
security: 68,
ux: 55,
},
},
{
id: 'photos',
displayName: 'Photos',
score: 62,
status: 'beta',
categories: {
backend: 82,
frontend: 65,
database: 72,
testing: 0,
deployment: 85,
documentation: 78,
security: 65,
ux: 55,
},
},
{
id: 'quotes',
displayName: 'Quotes',
score: 62,
status: 'beta',
categories: {
backend: 72,
frontend: 78,
database: 75,
testing: 0,
deployment: 92,
documentation: 20,
security: 70,
ux: 75,
},
},
{
id: 'context',
displayName: 'Context',
score: 60,
status: 'beta',
categories: {
backend: 75,
frontend: 75,
database: 82,
testing: 55,
deployment: 25,
documentation: 85,
security: 68,
ux: 65,
},
},
{
id: 'clock',
displayName: 'Clock',
score: 58,
status: 'beta',
categories: {
backend: 75,
frontend: 70,
database: 72,
testing: 0,
deployment: 88,
documentation: 10,
security: 60,
ux: 55,
},
},
{
id: 'skilltree',
displayName: 'SkillTree',
score: 58,
status: 'beta',
categories: {
backend: 65,
frontend: 68,
database: 72,
testing: 28,
deployment: 55,
documentation: 62,
security: 65,
ux: 72,
},
},
{
id: 'plants',
displayName: 'Plants',
score: 50,
status: 'alpha',
categories: {
backend: 68,
frontend: 58,
database: 70,
testing: 0,
deployment: 45,
documentation: 62,
security: 55,
ux: 50,
},
},
{
id: 'cards',
displayName: 'Cards',
score: 48,
status: 'alpha',
categories: {
backend: 50,
frontend: 65,
database: 30,
testing: 18,
deployment: 80,
documentation: 25,
security: 55,
ux: 68,
},
},
{
id: 'questions',
displayName: 'Questions',
score: 48,
status: 'alpha',
categories: {
backend: 88,
frontend: 62,
database: 78,
testing: 0,
deployment: 10,
documentation: 72,
security: 55,
ux: 55,
},
},
{
id: 'traces',
displayName: 'Traces',
score: 35,
status: 'alpha',
categories: {
backend: 72,
frontend: 10,
database: 70,
testing: 0,
deployment: 8,
documentation: 45,
security: 55,
ux: 35,
},
},
];
export function createMockEcosystem(): AppData[] {
return APP_DEFINITIONS.map((def) => {
const pos = APP_POSITIONS[def.id] || { x: 800, y: 500, lakeId: 'auth' };
const trend = def.previousScore ? def.score - def.previousScore : 0;
return {
id: def.id,
name: def.id,
displayName: def.displayName,
score: def.score,
status: def.status,
health: 'up' as const,
plantType: getPlantType(def.status, def.score),
categories: def.categories,
trend,
lakeId: pos.lakeId,
position: { x: pos.x, y: pos.y },
};
});
}

View file

@ -1,69 +0,0 @@
export type PlantType =
| 'oak'
| 'birch'
| 'youngTree'
| 'reed'
| 'waterLily'
| 'moss'
| 'shrub'
| 'sprout'
| 'stump'
| 'swampCluster';
export type AppStatus = 'prototype' | 'alpha' | 'beta' | 'production' | 'mature';
export type HealthStatus = 'up' | 'degraded' | 'down' | 'unknown';
export interface CategoryScores {
backend: number;
frontend: number;
database: number;
testing: number;
deployment: number;
documentation: number;
security: number;
ux: number;
}
export interface AppData {
id: string;
name: string;
displayName: string;
score: number;
status: AppStatus;
health: HealthStatus;
plantType: PlantType;
categories: CategoryScores;
trend: number;
lakeId: string;
position: { x: number; y: number };
}
export interface LakeData {
id: string;
name: string;
label: string;
path: string;
color: string;
colorDeep: string;
clarity: number; // 0-1, 1 = crystal clear
level: number; // 0-1, normalized fill level
position: { x: number; y: number };
}
export interface RiverData {
id: string;
from: string;
to: string;
path: string;
flowSpeed: number; // 0-1
width: number;
}
export interface EcosystemState {
apps: AppData[];
lakes: LakeData[];
rivers: RiverData[];
timeOfDay: number; // 0-24
systemHealth: 'sunny' | 'cloudy' | 'stormy';
}

View file

@ -1,63 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
import { vegetation } from '../data/colors';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
let spread = $derived(10 + (app.score / 100) * 15);
function mossPatches(seed: string) {
const patches = [];
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0;
}
const count = 4 + (Math.abs(hash) % 5);
for (let i = 0; i < count; i++) {
hash = ((hash << 5) - hash + i * 17) | 0;
patches.push({
cx: ((hash & 0xff) / 255 - 0.5) * spread * 2,
cy: (((hash >> 8) & 0xff) / 255 - 0.5) * spread * 0.8,
rx: 3 + ((hash >> 16) & 0x7),
ry: 2 + ((hash >> 20) & 0x5),
light: i % 3 === 0,
});
}
return patches;
}
let patches = $derived(mossPatches(app.id));
</script>
<g
class="plant-moss"
transform="translate({app.position.x}, {app.position.y})"
style="cursor: pointer;"
role="button"
tabindex="0"
{onclick}
onkeydown={(e) => e.key === 'Enter' && onclick?.()}
>
{#each patches as patch}
<ellipse
cx={patch.cx}
cy={patch.cy}
rx={patch.rx}
ry={patch.ry}
fill={patch.light ? vegetation.mossLight : vegetation.moss}
opacity="0.6"
/>
{/each}
<!-- Label -->
<text
y={spread * 0.5 + 12}
text-anchor="middle"
font-size="8"
font-family="system-ui, sans-serif"
fill="#5A7060"
opacity="0.5"
>
{app.displayName}
</text>
</g>

View file

@ -1,25 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
import TreePlant from './TreePlant.svelte';
import ReedPlant from './ReedPlant.svelte';
import WaterLily from './WaterLily.svelte';
import Sprout from './Sprout.svelte';
import MossCluster from './MossCluster.svelte';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
</script>
{#if app.plantType === 'oak' || app.plantType === 'birch' || app.plantType === 'youngTree'}
<TreePlant {app} {onclick} />
{:else if app.plantType === 'reed'}
<ReedPlant {app} {onclick} />
{:else if app.plantType === 'waterLily'}
<WaterLily {app} {onclick} />
{:else if app.plantType === 'sprout'}
<Sprout {app} {onclick} />
{:else if app.plantType === 'moss'}
<MossCluster {app} {onclick} />
{:else}
<!-- Fallback: render as sprout -->
<Sprout {app} {onclick} />
{/if}

View file

@ -1,98 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
import { vegetation } from '../data/colors';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
let height = $derived(25 + (app.score / 100) * 30); // 25-55px tall
let stalkCount = $derived(3 + Math.floor(app.score / 20)); // 3-8 stalks
let animDelay = $derived(`${(app.id.charCodeAt(0) % 8) * 0.4}s`);
function stalks(count: number) {
const result = [];
for (let i = 0; i < count; i++) {
const offset = (i - (count - 1) / 2) * 5;
const h = height + (i % 3) * 5 - 5;
const curve = offset * 0.5;
result.push({ offset, h, curve, hasBulrush: i % 2 === 0 });
}
return result;
}
let stalkData = $derived(stalks(stalkCount));
</script>
<g
class="plant-reed"
transform="translate({app.position.x}, {app.position.y})"
style="cursor: pointer;"
role="button"
tabindex="0"
{onclick}
onkeydown={(e) => e.key === 'Enter' && onclick?.()}
>
<g>
<animateTransform
attributeName="transform"
type="rotate"
values="-2,0,0; 2,0,0; -2,0,0"
dur="4s"
begin={animDelay}
repeatCount="indefinite"
/>
{#each stalkData as stalk}
<!-- Stalk -->
<path
d="M{stalk.offset},0 Q{stalk.offset + stalk.curve},{-stalk.h * 0.5} {stalk.offset +
stalk.curve * 1.2},{-stalk.h}"
fill="none"
stroke={vegetation.reed}
stroke-width="2"
stroke-linecap="round"
/>
<!-- Bulrush head (on some stalks) -->
{#if stalk.hasBulrush}
<ellipse
cx={stalk.offset + stalk.curve * 1.2}
cy={-stalk.h - 5}
rx="2.5"
ry="6"
fill={vegetation.reedDark}
/>
{/if}
{/each}
<!-- Leaf blades at base -->
<path
d="M-4,0 Q-10,-15 -8,-25"
fill="none"
stroke={vegetation.reed}
stroke-width="2.5"
stroke-linecap="round"
opacity="0.7"
/>
<path
d="M4,0 Q12,-12 10,-22"
fill="none"
stroke={vegetation.reed}
stroke-width="2.5"
stroke-linecap="round"
opacity="0.7"
/>
</g>
<!-- Label -->
<text
y="15"
text-anchor="middle"
font-size="9"
font-family="system-ui, sans-serif"
font-weight="500"
fill="#3A5040"
opacity="0.7"
>
{app.displayName}
</text>
</g>

View file

@ -1,81 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
import { vegetation } from '../data/colors';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
let height = $derived(12 + (app.score / 100) * 18); // 12-30px
let animDelay = $derived(`${(app.id.charCodeAt(0) % 5) * 0.6}s`);
</script>
<g
class="plant-sprout"
transform="translate({app.position.x}, {app.position.y})"
style="cursor: pointer;"
role="button"
tabindex="0"
{onclick}
onkeydown={(e) => e.key === 'Enter' && onclick?.()}
>
<g>
<animateTransform
attributeName="transform"
type="rotate"
values="-3,0,0; 3,0,0; -3,0,0"
dur="3.5s"
begin={animDelay}
repeatCount="indefinite"
/>
<!-- Support stake -->
<line
x1="3"
y1="2"
x2="3"
y2={-height - 5}
stroke={vegetation.sproutStake}
stroke-width="1.5"
stroke-linecap="round"
/>
<!-- Stem -->
<path
d="M0,0 Q-1,{-height * 0.5} 0,{-height}"
fill="none"
stroke={vegetation.sprout}
stroke-width="2"
stroke-linecap="round"
/>
<!-- Two small leaves -->
<path
d="M0,{-height * 0.6} Q-8,{-height * 0.7} -6,{-height * 0.85}"
fill={vegetation.sprout}
stroke={vegetation.sprout}
stroke-width="1"
opacity="0.8"
/>
<path
d="M0,{-height * 0.75} Q6,{-height * 0.8} 5,{-height * 0.95}"
fill={vegetation.sprout}
stroke={vegetation.sprout}
stroke-width="1"
opacity="0.8"
/>
<!-- Tiny leaf at top -->
<ellipse cx="0" cy={-height - 3} rx="3" ry="4" fill={vegetation.sprout} opacity="0.9" />
</g>
<!-- Label -->
<text
y="14"
text-anchor="middle"
font-size="8"
font-family="system-ui, sans-serif"
fill="#5A7060"
opacity="0.6"
>
{app.displayName}
</text>
</g>

View file

@ -1,117 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
import { vegetation, getHealthColor } from '../data/colors';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
let crownColor = $derived(getHealthColor(app.score));
let size = $derived(0.6 + (app.score / 100) * 0.6); // 0.6 - 1.2 scale
let isOak = $derived(app.plantType === 'oak');
// Generate pseudo-random but deterministic crown blobs
function crownBlobs(seed: string, count: number) {
const blobs = [];
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0;
}
for (let i = 0; i < count; i++) {
hash = ((hash << 5) - hash + i * 37) | 0;
const angle = ((hash & 0xff) / 255) * Math.PI * 2;
const dist = ((hash >> 8) & 0xff) / 255;
blobs.push({
cx: Math.cos(angle) * dist * (isOak ? 22 : 16),
cy: -30 - Math.sin(angle) * dist * (isOak ? 18 : 14) - i * 3,
rx: (isOak ? 18 : 14) + ((hash >> 16) & 0xf) - 8,
ry: (isOak ? 14 : 11) + ((hash >> 20) & 0xf) - 8,
});
}
return blobs;
}
let blobs = $derived(crownBlobs(app.id, isOak ? 7 : 5));
let animDelay = $derived(`${(app.id.charCodeAt(0) % 10) * 0.3}s`);
</script>
<g
class="plant-tree"
transform="translate({app.position.x}, {app.position.y}) scale({size})"
style="cursor: pointer;"
role="button"
tabindex="0"
{onclick}
onkeydown={(e) => e.key === 'Enter' && onclick?.()}
>
<!-- Wind sway animation wrapper -->
<g>
<animateTransform
attributeName="transform"
type="rotate"
values="-1,0,0; 1,0,0; -1,0,0"
dur="6s"
begin={animDelay}
repeatCount="indefinite"
/>
<!-- Shadow -->
<ellipse cx="8" cy="2" rx={isOak ? 20 : 14} ry="5" fill="black" opacity="0.1" />
<!-- Trunk -->
<path d="M-3,0 Q-4,-15 -2,-25 L2,-25 Q4,-15 3,0 Z" fill={vegetation.trunk} />
{#if isOak}
<!-- Branch left -->
<path d="M-2,-18 Q-12,-22 -16,-28 L-14,-30 Q-10,-24 -1,-20 Z" fill={vegetation.trunkLight} />
<!-- Branch right -->
<path d="M2,-20 Q10,-24 15,-30 L17,-28 Q12,-22 3,-18 Z" fill={vegetation.trunkLight} />
{/if}
<!-- Crown (cluster of ellipses) -->
<g>
{#each blobs as blob, i}
{@const lightness = i % 3 === 0 ? 15 : i % 3 === 1 ? 0 : -10}
<ellipse
cx={blob.cx}
cy={blob.cy}
rx={blob.rx}
ry={blob.ry}
fill={crownColor}
opacity={0.75 + (i % 3) * 0.08}
style="filter: brightness({100 + lightness}%)"
/>
{/each}
<!-- Highlight on top -->
<ellipse
cx={-4}
cy={-42}
rx={isOak ? 10 : 8}
ry={isOak ? 7 : 5}
fill="white"
opacity="0.08"
/>
</g>
</g>
<!-- Label -->
<text
y="18"
text-anchor="middle"
font-size="10"
font-family="system-ui, sans-serif"
font-weight="500"
fill="#3A5040"
opacity="0.8"
>
{app.displayName}
</text>
<text
y="30"
text-anchor="middle"
font-size="9"
font-family="system-ui, sans-serif"
fill="#5A7060"
opacity="0.6"
>
{app.score}
</text>
</g>

View file

@ -1,118 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
import { vegetation } from '../data/colors';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
let padSize = $derived(8 + (app.score / 100) * 10); // 8-18px radius
let petalCount = $derived(app.score >= 70 ? 8 : app.score >= 40 ? 6 : 4);
let bloomAmount = $derived(app.score / 100); // 0-1 how open the flower is
let animDelay = $derived(`${(app.id.charCodeAt(0) % 6) * 0.5}s`);
function petals(count: number) {
const result = [];
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2 - Math.PI / 2;
result.push({ angle, i });
}
return result;
}
let petalData = $derived(petals(petalCount));
</script>
<g
class="plant-lily"
transform="translate({app.position.x}, {app.position.y})"
style="cursor: pointer;"
role="button"
tabindex="0"
{onclick}
onkeydown={(e) => e.key === 'Enter' && onclick?.()}
>
<!-- Gentle bob animation -->
<g>
<animateTransform
attributeName="transform"
type="translate"
values="0,0; 0,-1.5; 0,0; 0,1; 0,0"
dur="5s"
begin={animDelay}
repeatCount="indefinite"
/>
<!-- Shadow under pad -->
<ellipse cx="1" cy="2" rx={padSize + 1} ry={padSize * 0.5 + 1} fill="black" opacity="0.1" />
<!-- Lily pad (circle with notch) -->
<path
d="M{padSize},0
A{padSize},{padSize * 0.55} 0 1,0 {-padSize},0
A{padSize},{padSize * 0.55} 0 1,0 {padSize},0
L0,0 L{padSize * 0.3},{-padSize * 0.15} Z"
fill={vegetation.lilyPad}
opacity="0.85"
/>
<!-- Pad vein -->
<line
x1="0"
y1="0"
x2={-padSize * 0.6}
y2={padSize * 0.15}
stroke="#3A7040"
stroke-width="0.5"
opacity="0.4"
/>
<line
x1="0"
y1="0"
x2={-padSize * 0.5}
y2={-padSize * 0.2}
stroke="#3A7040"
stroke-width="0.5"
opacity="0.4"
/>
<!-- Flower (if score > 20) -->
{#if app.score > 20}
<g transform="translate({-padSize * 0.2}, {-padSize * 0.1})">
<!-- Petals -->
{#each petalData as petal}
{@const px = Math.cos(petal.angle) * 5 * bloomAmount}
{@const py = Math.sin(petal.angle) * 3 * bloomAmount}
<ellipse
cx={px}
cy={py - 2}
rx={3.5 * bloomAmount + 1}
ry={2 * bloomAmount + 0.5}
fill={vegetation.lilyFlower}
opacity={0.7 + bloomAmount * 0.3}
transform="rotate({(petal.angle * 180) / Math.PI}, {px}, {py - 2})"
/>
{/each}
<!-- Center -->
<circle
cx="0"
cy="-2"
r={2 * bloomAmount + 0.5}
fill={vegetation.lilyFlowerCenter}
opacity="0.9"
/>
</g>
{/if}
</g>
<!-- Label -->
<text
y={padSize * 0.55 + 14}
text-anchor="middle"
font-size="9"
font-family="system-ui, sans-serif"
font-weight="500"
fill="#3A5040"
opacity="0.7"
>
{app.displayName}
</text>
</g>

View file

@ -1,67 +0,0 @@
<script lang="ts">
import { mountains } from '../data/colors';
import { SCENE, MOUNTAIN_PATHS } from '../data/layout';
import Sky from '../atmosphere/Sky.svelte';
let { hour = new Date().getHours() + new Date().getMinutes() / 60 }: { hour?: number } = $props();
</script>
<defs>
<!-- Subtle haze filter -->
<filter id="mountain-haze">
<feGaussianBlur stdDeviation="1.5" />
</filter>
<filter id="mountain-haze-light">
<feGaussianBlur stdDeviation="0.8" />
</filter>
</defs>
<!-- Dynamic sky -->
<Sky {hour} />
<!-- Clouds (subtle, decorative) -->
<g opacity="0.35">
<ellipse cx="300" cy="120" rx="120" ry="30" fill="white" />
<ellipse cx="340" cy="115" rx="80" ry="25" fill="white" />
<ellipse cx="260" cy="118" rx="60" ry="20" fill="white" />
<ellipse cx="1100" cy="90" rx="100" ry="25" fill="white" />
<ellipse cx="1140" cy="85" rx="70" ry="22" fill="white" />
<ellipse cx="700" cy="150" rx="90" ry="22" fill="white" />
<ellipse cx="740" cy="145" rx="65" ry="18" fill="white" />
</g>
<!-- Mountain layers (back to front) -->
<g>
<!-- Far mountains (lightest, most hazy) -->
<path d={MOUNTAIN_PATHS.far} fill={mountains.far} opacity="0.6" filter="url(#mountain-haze)" />
<!-- Snow caps on far mountains -->
<path
d="M740,165 Q755,155 770,168 Q782,158 795,170 L790,180 Q775,172 760,180 Q748,172 740,178 Z"
fill={mountains.snow}
opacity="0.5"
filter="url(#mountain-haze)"
/>
<!-- Mid mountains -->
<path
d={MOUNTAIN_PATHS.mid}
fill={mountains.mid}
opacity="0.75"
filter="url(#mountain-haze-light)"
/>
<!-- Near mountains (darkest, sharpest) -->
<path d={MOUNTAIN_PATHS.near} fill={mountains.near} opacity="0.85" />
</g>
<!-- Tree line on near mountains (tiny triangles) -->
<g opacity="0.4" fill="#3A6050">
{#each Array(60) as _, i}
{@const x = i * 28 + 10}
{@const baseY = 335 + Math.sin(i * 0.8) * 15}
{@const h = 12 + Math.sin(i * 1.3) * 5}
<polygon points="{x},{baseY} {x + 4},{baseY - h} {x + 8},{baseY}" />
{/each}
</g>

View file

@ -1,59 +0,0 @@
<script lang="ts">
import { terrain } from '../data/colors';
import { SCENE } from '../data/layout';
</script>
<defs>
<!-- Meadow gradient (top = darker, bottom = lighter) -->
<linearGradient id="meadow-gradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color={terrain.meadowDark} />
<stop offset="40%" stop-color={terrain.meadow} />
<stop offset="100%" stop-color={terrain.meadowLight} />
</linearGradient>
<!-- Shore texture pattern -->
<pattern id="shore-dots" width="8" height="8" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="0.8" fill={terrain.shoreDark} opacity="0.3" />
<circle cx="6" cy="6" r="0.6" fill={terrain.shoreDark} opacity="0.2" />
</pattern>
</defs>
<!-- Main terrain fill (everything below mountains) -->
<rect x="0" y="350" width={SCENE.width} height={SCENE.height - 350} fill="url(#meadow-gradient)" />
<!-- Rolling hills / terrain variation -->
<g>
<!-- Gentle hills in the meadow -->
<path
d="M0,380 Q200,360 400,375 Q600,365 800,378 Q1000,362 1200,372 Q1400,365 1600,380 L1600,420 Q1400,405 1200,410 Q1000,400 800,412 Q600,402 400,408 Q200,398 0,410 Z"
fill={terrain.meadow}
opacity="0.5"
/>
<!-- Shoreline areas around the lakes (sandy patches) -->
<ellipse cx="790" cy="485" rx="160" ry="95" fill={terrain.shore} opacity="0.3" />
<ellipse cx="340" cy="420" rx="90" ry="55" fill={terrain.shore} opacity="0.25" />
<ellipse cx="1275" cy="445" rx="130" ry="75" fill={terrain.shore} opacity="0.25" />
<ellipse cx="270" cy="625" rx="130" ry="75" fill={terrain.shore} opacity="0.3" />
<ellipse cx="760" cy="670" rx="140" ry="80" fill={terrain.shore} opacity="0.3" />
<ellipse cx="1215" cy="635" rx="125" ry="70" fill={terrain.shore} opacity="0.3" />
</g>
<!-- Small rocks scattered around -->
<g fill={terrain.rock} opacity="0.5">
<ellipse cx="520" cy="450" rx="8" ry="5" />
<ellipse cx="525" cy="448" rx="5" ry="3" fill={terrain.rockLight} />
<ellipse cx="1050" cy="520" rx="6" ry="4" />
<ellipse cx="450" cy="650" rx="7" ry="4" />
<ellipse cx="950" cy="710" rx="9" ry="5" />
<ellipse cx="1400" cy="550" rx="5" ry="3" />
</g>
<!-- Grass tufts along the bottom -->
<g opacity="0.4" fill={terrain.meadowDark}>
{#each Array(40) as _, i}
{@const x = i * 42 + 15}
{@const y = 820 + Math.sin(i * 2.1) * 20}
<path d="M{x},{y} Q{x + 2},{y - 12} {x + 5},{y} Q{x + 7},{y - 10} {x + 10},{y}" />
{/each}
</g>

View file

@ -1,70 +0,0 @@
<script lang="ts">
import type { LakeData } from '../data/types';
let { lake }: { lake: LakeData } = $props();
// Unique IDs for gradients
let gradientId = $derived(`lake-gradient-${lake.id}`);
let reflectionId = $derived(`lake-reflection-${lake.id}`);
</script>
<defs>
<!-- Radial gradient for depth effect -->
<radialGradient id={gradientId} cx="50%" cy="40%" r="60%">
<stop offset="0%" stop-color={lake.color} stop-opacity={lake.clarity} />
<stop offset="100%" stop-color={lake.colorDeep} stop-opacity={lake.clarity * 0.9} />
</radialGradient>
<!-- Subtle shimmer/reflection -->
<radialGradient id={reflectionId} cx="40%" cy="30%" r="30%">
<stop offset="0%" stop-color="white" stop-opacity="0.15" />
<stop offset="100%" stop-color="white" stop-opacity="0" />
</radialGradient>
</defs>
<g class="lake" data-lake={lake.id}>
<!-- Lake body -->
<path
d={lake.path}
fill="url(#{gradientId})"
stroke="#3A8A9A"
stroke-width="0.5"
stroke-opacity="0.3"
/>
<!-- Light reflection on surface -->
<path d={lake.path} fill="url(#{reflectionId})" />
<!-- Animated wave lines on the surface -->
<g opacity="0.15" stroke="white" stroke-width="0.8" fill="none">
<path d={lake.path} stroke-dasharray="4 12" stroke-dashoffset="0">
<animate attributeName="stroke-dashoffset" values="0;16" dur="4s" repeatCount="indefinite" />
</path>
</g>
<!-- Second wave layer (offset timing) -->
<g opacity="0.1" stroke="white" stroke-width="0.5" fill="none">
<path d={lake.path} stroke-dasharray="3 15" stroke-dashoffset="8">
<animate
attributeName="stroke-dashoffset"
values="8;26"
dur="5.5s"
repeatCount="indefinite"
/>
</path>
</g>
<!-- Lake label -->
<text
x={lake.position.x}
y={lake.position.y}
text-anchor="middle"
font-size="11"
font-family="serif"
font-style="italic"
fill="#1A5666"
opacity="0.6"
>
{lake.label}
</text>
</g>

View file

@ -1,359 +0,0 @@
<script lang="ts">
import type { AppData, CategoryScores } from '../data/types';
import RadarChart from './RadarChart.svelte';
let { apps }: { apps: AppData[] } = $props();
let selected = $state<string[]>([]);
const colors = ['#3b82f6', '#f59e0b', '#10b981', '#ef4444'];
const statusColors: Record<string, string> = {
mature: '#34d399',
production: '#60a5fa',
beta: '#fbbf24',
alpha: '#f97316',
};
function toggle(id: string) {
if (selected.includes(id)) {
selected = selected.filter((s) => s !== id);
} else if (selected.length < 4) {
selected = [...selected, id];
}
}
let selectedApps = $derived(selected.map((id) => apps.find((a) => a.id === id)!).filter(Boolean));
const categoryKeys: (keyof CategoryScores)[] = [
'backend',
'frontend',
'database',
'testing',
'deployment',
'documentation',
'security',
'ux',
];
const labels = [
{ key: 'backend', label: 'Backend' },
{ key: 'frontend', label: 'Frontend' },
{ key: 'database', label: 'Database' },
{ key: 'testing', label: 'Testing' },
{ key: 'deployment', label: 'Deploy' },
{ key: 'documentation', label: 'Docs' },
{ key: 'security', label: 'Security' },
{ key: 'ux', label: 'UX' },
] as const;
const size = 280;
let center = $derived(size / 2);
let maxR = $derived(size / 2 - 30);
function polarToXY(angle: number, radius: number) {
const rad = ((angle - 90) * Math.PI) / 180;
return { x: center + Math.cos(rad) * radius, y: center + Math.sin(rad) * radius };
}
let rings = $derived(
[0.25, 0.5, 0.75, 1].map((pct) => {
const r = maxR * pct;
return labels
.map((_, i) => {
const angle = (360 / labels.length) * i;
return polarToXY(angle, r);
})
.map((p) => `${p.x},${p.y}`)
.join(' ');
})
);
let axes = $derived(labels.map((_, i) => polarToXY((360 / labels.length) * i, maxR)));
let labelPositions = $derived(
labels.map((l, i) => {
const pos = polarToXY((360 / labels.length) * i, maxR + 20);
return { ...pos, label: l.label };
})
);
function appPolygon(app: AppData): string {
return labels
.map((l, i) => {
const angle = (360 / labels.length) * i;
const value = app.categories[l.key] / 100;
const p = polarToXY(angle, maxR * value);
return `${p.x},${p.y}`;
})
.join(' ');
}
</script>
<div class="compare-view">
<!-- App selector -->
<div class="selector">
<p class="selector-label">Apps auswahlen (max. 4):</p>
<div class="selector-chips">
{#each apps as app (app.id)}
<button
type="button"
class="chip"
class:active={selected.includes(app.id)}
style={selected.includes(app.id)
? `border-color: ${colors[selected.indexOf(app.id)]}; color: ${colors[selected.indexOf(app.id)]}`
: ''}
onclick={() => toggle(app.id)}
>
<span class="chip-dot" style="background: {statusColors[app.status]}"></span>
{app.displayName}
<span class="chip-score">{app.score}</span>
</button>
{/each}
</div>
</div>
{#if selectedApps.length >= 2}
<div class="compare-content">
<!-- Overlaid radar chart -->
<div class="radar-section">
<svg width={size} height={size} viewBox="0 0 {size} {size}">
{#each rings as ring}
<polygon
points={ring}
fill="none"
stroke="rgba(148, 163, 184, 0.12)"
stroke-width="0.5"
/>
{/each}
{#each axes as axis}
<line
x1={center}
y1={center}
x2={axis.x}
y2={axis.y}
stroke="rgba(148, 163, 184, 0.08)"
stroke-width="0.5"
/>
{/each}
{#each selectedApps as app, i (app.id)}
<polygon
points={appPolygon(app)}
fill="{colors[i]}20"
stroke={colors[i]}
stroke-width="2"
opacity="0.8"
/>
{/each}
{#each labelPositions as lp}
<text
x={lp.x}
y={lp.y}
text-anchor="middle"
dominant-baseline="central"
font-size="9"
font-family="system-ui, sans-serif"
fill="#94a3b8"
>
{lp.label}
</text>
{/each}
</svg>
<!-- Legend -->
<div class="radar-legend">
{#each selectedApps as app, i (app.id)}
<span class="legend-item">
<span class="legend-color" style="background: {colors[i]}"></span>
{app.displayName} ({app.score})
</span>
{/each}
</div>
</div>
<!-- Category comparison bars -->
<div class="bars-section">
{#each categoryKeys as cat}
<div class="bar-group">
<span class="bar-label">{cat}</span>
<div class="bar-rows">
{#each selectedApps as app, i (app.id)}
<div class="bar-row">
<div class="bar-track">
<div
class="bar-fill"
style="width: {app.categories[cat]}%; background: {colors[i]}"
></div>
</div>
<span class="bar-value">{app.categories[cat]}</span>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
{:else}
<div class="compare-placeholder">
<p>Wahle mindestens 2 Apps zum Vergleichen</p>
</div>
{/if}
</div>
<style>
.compare-view {
display: flex;
flex-direction: column;
gap: 24px;
}
.selector-label {
font-size: 12px;
color: #94a3b8;
margin: 0 0 10px 0;
}
.selector-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.15);
background: rgba(15, 23, 42, 0.4);
color: #94a3b8;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.chip:hover {
border-color: rgba(148, 163, 184, 0.3);
color: #cbd5e1;
}
.chip.active {
background: rgba(59, 130, 246, 0.1);
}
.chip-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.chip-score {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.compare-content {
display: grid;
grid-template-columns: auto 1fr;
gap: 32px;
align-items: start;
}
@media (max-width: 768px) {
.compare-content {
grid-template-columns: 1fr;
}
}
.radar-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.radar-legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
}
.legend-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: #cbd5e1;
}
.legend-color {
width: 10px;
height: 10px;
border-radius: 3px;
}
.bars-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.bar-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.bar-label {
font-size: 10px;
color: #64748b;
text-transform: capitalize;
font-weight: 600;
}
.bar-rows {
display: flex;
flex-direction: column;
gap: 3px;
}
.bar-row {
display: flex;
align-items: center;
gap: 8px;
}
.bar-track {
flex: 1;
height: 6px;
background: rgba(148, 163, 184, 0.08);
border-radius: 3px;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
}
.bar-value {
font-size: 10px;
color: #94a3b8;
width: 22px;
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.compare-placeholder {
text-align: center;
padding: 48px 0;
color: #64748b;
font-size: 14px;
}
</style>

View file

@ -1,331 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
import RadarChart from './RadarChart.svelte';
let {
app,
onclose,
}: {
app: AppData | null;
onclose: () => void;
} = $props();
const statusLabels: Record<string, string> = {
mature: 'Mature',
production: 'Production',
beta: 'Beta',
alpha: 'Alpha',
prototype: 'Prototype',
};
const statusColors: Record<string, string> = {
mature: '#34d399',
production: '#60a5fa',
beta: '#fbbf24',
alpha: '#f97316',
prototype: '#94a3b8',
};
const appUrls: Record<string, string> = {
calendar: 'https://calendar.mana.how',
todo: 'https://todo.mana.how',
contacts: 'https://contacts.mana.how',
chat: 'https://chat.mana.how',
storage: 'https://storage.mana.how',
picture: 'https://picture.mana.how',
presi: 'https://presi.mana.how',
music: 'https://music.mana.how',
clock: 'https://clock.mana.how',
food: 'https://food.mana.how',
photos: 'https://photos.mana.how',
quotes: 'https://quotes.mana.how',
mana: 'https://mana.how',
cards: 'https://cards.mana.how',
plants: 'https://plants.mana.how',
playground: 'https://playground.mana.how',
};
function scoreColor(score: number): string {
if (score >= 85) return '#34d399';
if (score >= 70) return '#60a5fa';
if (score >= 55) return '#fbbf24';
if (score >= 40) return '#f97316';
return '#ef4444';
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onclose();
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if app}
<!-- Backdrop -->
<button
type="button"
class="panel-backdrop"
onclick={onclose}
tabindex="-1"
aria-label="Close panel"
></button>
<div class="panel" class:open={!!app}>
<!-- Header -->
<div class="panel-header">
<div>
<h3 class="panel-title">{app.displayName}</h3>
<span class="panel-status" style="color: {statusColors[app.status]}">
{statusLabels[app.status]}
</span>
</div>
<div class="panel-score" style="color: {scoreColor(app.score)}">
{app.score}
</div>
</div>
<!-- Score bar -->
<div class="score-bar-container">
<div class="score-bar" style="width: {app.score}%; background: {scoreColor(app.score)}"></div>
</div>
<!-- Radar Chart -->
<div class="panel-section">
<h4 class="section-label">Kategorien</h4>
<div class="radar-container">
<RadarChart categories={app.categories} size={200} />
</div>
</div>
<!-- Category scores list -->
<div class="panel-section">
<div class="category-list">
{#each Object.entries(app.categories) as [key, value]}
<div class="category-row">
<span class="category-label">{key}</span>
<div class="category-bar-wrap">
<div
class="category-bar-fill"
style="width: {value}%; background: {scoreColor(value)}"
></div>
</div>
<span class="category-value">{value}</span>
</div>
{/each}
</div>
</div>
<!-- Links -->
<div class="panel-section">
<div class="panel-links">
{#if appUrls[app.id]}
<a href={appUrls[app.id]} target="_blank" rel="noopener" class="panel-link">
App offnen
</a>
{/if}
<a href="/manascore/{app.id}" class="panel-link secondary"> ManaScore Details </a>
</div>
</div>
<!-- Close button -->
<button type="button" class="panel-close" onclick={onclose} aria-label="Close">
&times;
</button>
</div>
{/if}
<style>
.panel-backdrop {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(0, 0, 0, 0.2);
border: none;
cursor: default;
}
.panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 45;
width: 340px;
max-width: 90vw;
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(16px);
border-left: 1px solid rgba(148, 163, 184, 0.1);
overflow-y: auto;
padding: 24px;
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
animation: slideIn 0.25s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.panel-title {
font-size: 20px;
font-weight: 700;
color: #f1f5f9;
margin: 0;
}
.panel-status {
font-size: 12px;
font-weight: 500;
}
.panel-score {
font-size: 36px;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.score-bar-container {
height: 4px;
background: rgba(148, 163, 184, 0.1);
border-radius: 2px;
margin-bottom: 24px;
overflow: hidden;
}
.score-bar {
height: 100%;
border-radius: 2px;
transition: width 0.5s ease;
}
.panel-section {
margin-bottom: 20px;
}
.section-label {
font-size: 10px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 10px 0;
}
.radar-container {
display: flex;
justify-content: center;
}
.category-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.category-row {
display: flex;
align-items: center;
gap: 8px;
}
.category-label {
font-size: 11px;
color: #94a3b8;
width: 90px;
text-transform: capitalize;
flex-shrink: 0;
}
.category-bar-wrap {
flex: 1;
height: 5px;
background: rgba(148, 163, 184, 0.1);
border-radius: 3px;
overflow: hidden;
}
.category-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
}
.category-value {
font-size: 11px;
color: #cbd5e1;
width: 24px;
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.panel-links {
display: flex;
flex-direction: column;
gap: 8px;
}
.panel-link {
display: block;
text-align: center;
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
text-decoration: none;
background: #3b82f6;
color: white;
transition: background 0.2s;
}
.panel-link:hover {
background: #2563eb;
}
.panel-link.secondary {
background: rgba(148, 163, 184, 0.1);
color: #94a3b8;
}
.panel-link.secondary:hover {
background: rgba(148, 163, 184, 0.2);
color: #cbd5e1;
}
.panel-close {
position: absolute;
top: 16px;
right: 16px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: rgba(148, 163, 184, 0.1);
color: #94a3b8;
border-radius: 6px;
font-size: 18px;
cursor: pointer;
transition:
background 0.2s,
color 0.2s;
}
.panel-close:hover {
background: rgba(148, 163, 184, 0.2);
color: #f1f5f9;
}
</style>

View file

@ -1,177 +0,0 @@
<script lang="ts">
import type { LakeData } from '../data/types';
let { lake }: { lake: LakeData } = $props();
// Normalize the lake path to fit inside the card SVG
// We'll render it at its original scale but crop via viewBox
let viewBox = $derived(() => {
// Parse path to get rough bounds
const nums = lake.path.match(/[\d.]+/g)?.map(Number) || [];
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (let i = 0; i < nums.length; i += 2) {
if (nums[i] < minX) minX = nums[i];
if (nums[i] > maxX) maxX = nums[i];
}
for (let i = 1; i < nums.length; i += 2) {
if (nums[i] < minY) minY = nums[i];
if (nums[i] > maxY) maxY = nums[i];
}
const pad = 20;
return `${minX - pad} ${minY - pad} ${maxX - minX + pad * 2} ${maxY - minY + pad * 2}`;
});
const lakeDescriptions: Record<string, string> = {
auth: 'Zentraler Authentifizierungs-Hub. Alle Services fliessen durch diesen See.',
redis: 'Schneller Cache-Speicher. Klein, kristallklar, sofort verfugbar.',
minio: 'Objekt-Speicher fur Dateien, Bilder und Medien aller Apps.',
'db-left': 'PostgreSQL-Datenbanken fur Calendar, Todo, Contacts, Storage.',
'db-center': 'PostgreSQL-Datenbanken fur Quotes, Music, Clock, Food.',
'db-right': 'PostgreSQL-Datenbanken fur Photos, SkillTree, Context, Plants.',
};
const lakeIcons: Record<string, string> = {
auth: 'Mana Core Auth',
redis: 'Redis Cache',
minio: 'MinIO Storage',
'db-left': 'PostgreSQL West',
'db-center': 'PostgreSQL Mitte',
'db-right': 'PostgreSQL Ost',
};
</script>
<div class="lake-card">
<!-- Lake SVG preview -->
<div class="lake-preview">
<svg
width="220"
height="120"
viewBox={viewBox()}
preserveAspectRatio="xMidYMid meet"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<radialGradient id="lc-{lake.id}" cx="50%" cy="40%" r="60%">
<stop offset="0%" stop-color={lake.color} />
<stop offset="100%" stop-color={lake.colorDeep} />
</radialGradient>
</defs>
<path d={lake.path} fill="url(#lc-{lake.id})" />
<!-- Shimmer -->
<path d={lake.path} fill="white" opacity="0.08" />
<!-- Waves -->
<path
d={lake.path}
fill="none"
stroke="white"
stroke-width="0.8"
stroke-dasharray="4 12"
opacity="0.2"
>
<animate
attributeName="stroke-dashoffset"
values="0;16"
dur="4s"
repeatCount="indefinite"
/>
</path>
</svg>
</div>
<!-- Info -->
<div class="lake-info">
<h3 class="lake-name">{lake.label}</h3>
<span class="lake-service">{lakeIcons[lake.id] || lake.name}</span>
<p class="lake-desc">{lakeDescriptions[lake.id] || ''}</p>
<div class="lake-stats">
<span class="lake-stat">
<span class="stat-label">Klarheit</span>
<span class="stat-value">{Math.round(lake.clarity * 100)}%</span>
</span>
<span class="lake-stat">
<span class="stat-label">Fullstand</span>
<span class="stat-value">{Math.round(lake.level * 100)}%</span>
</span>
</div>
</div>
</div>
<style>
.lake-card {
flex-shrink: 0;
width: 260px;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(8px);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 12px;
overflow: hidden;
transition:
transform 0.2s,
border-color 0.2s;
}
.lake-card:hover {
transform: translateY(-3px);
border-color: rgba(59, 130, 246, 0.2);
}
.lake-preview {
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
padding: 12px;
}
.lake-info {
padding: 12px 14px;
}
.lake-name {
font-size: 15px;
font-weight: 600;
color: #f1f5f9;
margin: 0 0 2px 0;
}
.lake-service {
font-size: 11px;
color: #60a5fa;
font-weight: 500;
}
.lake-desc {
font-size: 11px;
color: #94a3b8;
margin: 8px 0;
line-height: 1.4;
}
.lake-stats {
display: flex;
gap: 16px;
}
.lake-stat {
display: flex;
flex-direction: column;
gap: 1px;
}
.stat-label {
font-size: 9px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
font-size: 13px;
font-weight: 600;
color: #cbd5e1;
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -1,295 +0,0 @@
<script lang="ts">
import type { AppData, CategoryScores } from '../data/types';
let { apps, onselect }: { apps: AppData[]; onselect: (app: AppData) => void } = $props();
type SortKey = 'score' | 'name' | 'trend' | keyof CategoryScores;
let sortKey = $state<SortKey>('score');
let sortAsc = $state(false);
const categoryKeys: (keyof CategoryScores)[] = [
'backend',
'frontend',
'database',
'testing',
'deployment',
'documentation',
'security',
'ux',
];
const shortLabels: Record<string, string> = {
backend: 'BE',
frontend: 'FE',
database: 'DB',
testing: 'Test',
deployment: 'Deploy',
documentation: 'Docs',
security: 'Sec',
ux: 'UX',
};
const statusColors: Record<string, string> = {
mature: '#34d399',
production: '#60a5fa',
beta: '#fbbf24',
alpha: '#f97316',
prototype: '#94a3b8',
};
function scoreColor(score: number): string {
if (score >= 85) return '#34d399';
if (score >= 70) return '#60a5fa';
if (score >= 50) return '#fbbf24';
if (score >= 30) return '#f97316';
return '#ef4444';
}
function toggleSort(key: SortKey) {
if (sortKey === key) {
sortAsc = !sortAsc;
} else {
sortKey = key;
sortAsc = false;
}
}
let sorted = $derived(() => {
const arr = [...apps];
arr.sort((a, b) => {
let va: number | string, vb: number | string;
if (sortKey === 'score') {
va = a.score;
vb = b.score;
} else if (sortKey === 'name') {
va = a.displayName.toLowerCase();
vb = b.displayName.toLowerCase();
} else if (sortKey === 'trend') {
va = a.trend;
vb = b.trend;
} else {
va = a.categories[sortKey];
vb = b.categories[sortKey];
}
if (va < vb) return sortAsc ? -1 : 1;
if (va > vb) return sortAsc ? 1 : -1;
return 0;
});
return arr;
});
function sortIndicator(key: SortKey): string {
if (sortKey !== key) return '';
return sortAsc ? ' ↑' : ' ↓';
}
</script>
<div class="leaderboard">
<div class="table-scroll">
<table>
<thead>
<tr>
<th class="rank-col">#</th>
<th class="name-col sortable" onclick={() => toggleSort('name')}>
App{sortIndicator('name')}
</th>
<th class="score-col sortable" onclick={() => toggleSort('score')}>
Score{sortIndicator('score')}
</th>
<th class="trend-col sortable" onclick={() => toggleSort('trend')}>
Trend{sortIndicator('trend')}
</th>
{#each categoryKeys as cat}
<th class="cat-col sortable" onclick={() => toggleSort(cat)}>
{shortLabels[cat]}{sortIndicator(cat)}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sorted() as app, i (app.id)}
<tr class="app-row" onclick={() => onselect(app)}>
<td class="rank">{i + 1}</td>
<td class="name-cell">
<span class="status-dot" style="background: {statusColors[app.status]}"></span>
{app.displayName}
</td>
<td class="score-cell" style="color: {scoreColor(app.score)}">
{app.score}
</td>
<td class="trend-cell">
{#if app.trend > 0}
<span class="trend-up">+{app.trend}</span>
{:else if app.trend < 0}
<span class="trend-down">{app.trend}</span>
{:else}
<span class="trend-neutral">-</span>
{/if}
</td>
{#each categoryKeys as cat}
<td class="cat-cell">
<div class="cat-bar-wrap">
<div
class="cat-bar-fill"
style="width: {app.categories[cat]}%; background: {scoreColor(
app.categories[cat]
)}"
></div>
</div>
<span class="cat-value">{app.categories[cat]}</span>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<style>
.leaderboard {
width: 100%;
}
.table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 800px;
}
thead tr {
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
th {
font-size: 10px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 8px 6px;
text-align: left;
white-space: nowrap;
user-select: none;
}
th.sortable {
cursor: pointer;
}
th.sortable:hover {
color: #94a3b8;
}
.rank-col {
width: 32px;
text-align: center;
}
.name-col {
width: 120px;
}
.score-col {
width: 56px;
text-align: right;
}
.trend-col {
width: 48px;
text-align: right;
}
.cat-col {
width: 70px;
text-align: right;
}
.app-row {
border-bottom: 1px solid rgba(148, 163, 184, 0.05);
cursor: pointer;
transition: background 0.15s;
}
.app-row:hover {
background: rgba(59, 130, 246, 0.06);
}
td {
padding: 8px 6px;
font-size: 12px;
color: #cbd5e1;
}
.rank {
text-align: center;
color: #64748b;
font-weight: 600;
font-size: 11px;
}
.name-cell {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
color: #f1f5f9;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.score-cell {
text-align: right;
font-weight: 700;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.trend-cell {
text-align: right;
font-size: 11px;
font-weight: 600;
}
.trend-up {
color: #34d399;
}
.trend-down {
color: #ef4444;
}
.trend-neutral {
color: #475569;
}
.cat-cell {
text-align: right;
}
.cat-bar-wrap {
display: inline-block;
width: 32px;
height: 4px;
background: rgba(148, 163, 184, 0.1);
border-radius: 2px;
overflow: hidden;
vertical-align: middle;
margin-right: 4px;
}
.cat-bar-fill {
height: 100%;
border-radius: 2px;
}
.cat-value {
font-size: 10px;
font-variant-numeric: tabular-nums;
color: #94a3b8;
}
</style>

View file

@ -1,185 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
import { getHealthColor } from '../data/colors';
import PlantFactory from '../plants/PlantFactory.svelte';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
const statusLabels: Record<string, string> = {
mature: 'Mature',
production: 'Production',
beta: 'Beta',
alpha: 'Alpha',
prototype: 'Prototype',
};
const statusColors: Record<string, string> = {
mature: '#34d399',
production: '#60a5fa',
beta: '#fbbf24',
alpha: '#f97316',
prototype: '#94a3b8',
};
const plantTypeLabels: Record<string, string> = {
oak: 'Eiche',
birch: 'Birke',
youngTree: 'Junger Baum',
reed: 'Schilf',
waterLily: 'Seerose',
moss: 'Moos',
shrub: 'Busch',
sprout: 'Sprossling',
stump: 'Baumstumpf',
swampCluster: 'Sumpfpflanze',
};
// Create a centered version of the app for the card SVG
let cardApp = $derived({
...app,
position: { x: 80, y: 120 },
});
function scoreColor(score: number): string {
if (score >= 85) return '#34d399';
if (score >= 70) return '#60a5fa';
if (score >= 55) return '#fbbf24';
if (score >= 40) return '#f97316';
return '#ef4444';
}
</script>
<button type="button" class="plant-card" {onclick}>
<!-- Plant SVG preview -->
<div class="plant-preview">
<svg width="160" height="160" viewBox="0 0 160 160" xmlns="http://www.w3.org/2000/svg">
<!-- Ground line -->
<ellipse cx="80" cy="125" rx="50" ry="6" fill="#7DB86A" opacity="0.3" />
<PlantFactory app={cardApp} />
</svg>
</div>
<!-- Info -->
<div class="plant-info">
<div class="plant-header">
<span class="plant-name">{app.displayName}</span>
<span class="plant-score" style="color: {scoreColor(app.score)}">{app.score}</span>
</div>
<div class="plant-meta">
<span class="plant-type">{plantTypeLabels[app.plantType] || app.plantType}</span>
<span class="plant-status" style="color: {statusColors[app.status]}">
{statusLabels[app.status]}
</span>
</div>
<!-- Mini score bar -->
<div class="plant-bar">
<div
class="plant-bar-fill"
style="width: {app.score}%; background: {scoreColor(app.score)}"
></div>
</div>
{#if app.trend !== 0}
<div class="plant-trend" class:positive={app.trend > 0} class:negative={app.trend < 0}>
{app.trend > 0 ? '+' : ''}{app.trend} seit letztem Audit
</div>
{/if}
</div>
</button>
<style>
.plant-card {
flex-shrink: 0;
width: 200px;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(8px);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition:
transform 0.2s,
border-color 0.2s,
box-shadow 0.2s;
text-align: left;
color: inherit;
padding: 0;
}
.plant-card:hover {
transform: translateY(-4px);
border-color: rgba(59, 130, 246, 0.3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.plant-preview {
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(180deg, #e0f2fe 0%, #bae6fd 40%, #7db86a 100%);
padding: 8px 0 0;
}
.plant-info {
padding: 12px;
}
.plant-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
}
.plant-name {
font-size: 14px;
font-weight: 600;
color: #f1f5f9;
}
.plant-score {
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.plant-meta {
display: flex;
justify-content: space-between;
font-size: 11px;
margin-bottom: 8px;
}
.plant-type {
color: #64748b;
}
.plant-status {
font-weight: 500;
}
.plant-bar {
height: 3px;
background: rgba(148, 163, 184, 0.15);
border-radius: 2px;
overflow: hidden;
}
.plant-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.4s ease;
}
.plant-trend {
font-size: 10px;
margin-top: 6px;
}
.plant-trend.positive {
color: #34d399;
}
.plant-trend.negative {
color: #ef4444;
}
</style>

View file

@ -1,176 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
let { app, x, y }: { app: AppData; x: number; y: number } = $props();
const statusLabels: Record<string, string> = {
mature: 'Mature',
production: 'Production',
beta: 'Beta',
alpha: 'Alpha',
prototype: 'Prototype',
};
const statusColors: Record<string, string> = {
mature: '#34d399',
production: '#60a5fa',
beta: '#fbbf24',
alpha: '#f97316',
prototype: '#94a3b8',
};
const healthIcons: Record<string, string> = {
up: '●',
degraded: '◐',
down: '○',
unknown: '?',
};
const healthColors: Record<string, string> = {
up: '#34d399',
degraded: '#fbbf24',
down: '#ef4444',
unknown: '#94a3b8',
};
</script>
<div class="plant-tooltip" style="left: {x}px; top: {y}px;">
<div class="tooltip-header">
<span class="tooltip-name">{app.displayName}</span>
<span class="tooltip-score">{app.score}</span>
</div>
<div class="tooltip-meta">
<span class="tooltip-status" style="color: {statusColors[app.status]}">
{statusLabels[app.status]}
</span>
<span class="tooltip-health" style="color: {healthColors[app.health]}">
{healthIcons[app.health]}
</span>
{#if app.trend !== 0}
<span class="tooltip-trend" class:positive={app.trend > 0} class:negative={app.trend < 0}>
{app.trend > 0 ? '+' : ''}{app.trend}
</span>
{/if}
</div>
<div class="tooltip-categories">
{#each Object.entries(app.categories) as [key, value]}
<div class="tooltip-cat-row">
<span class="tooltip-cat-label">{key}</span>
<div class="tooltip-cat-bar">
<div class="tooltip-cat-fill" style="width: {value}%"></div>
</div>
<span class="tooltip-cat-value">{value}</span>
</div>
{/each}
</div>
</div>
<style>
.plant-tooltip {
position: fixed;
z-index: 50;
pointer-events: none;
transform: translate(-50%, -100%) translateY(-12px);
background: rgba(15, 23, 42, 0.92);
backdrop-filter: blur(8px);
border: 1px solid rgba(148, 163, 184, 0.15);
border-radius: 10px;
padding: 10px 14px;
min-width: 180px;
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(255, 255, 255, 0.05);
}
.tooltip-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
}
.tooltip-name {
font-size: 13px;
font-weight: 600;
color: #f1f5f9;
}
.tooltip-score {
font-size: 18px;
font-weight: 700;
color: #f1f5f9;
font-variant-numeric: tabular-nums;
}
.tooltip-meta {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
font-size: 11px;
}
.tooltip-status {
font-weight: 500;
}
.tooltip-health {
font-size: 8px;
}
.tooltip-trend {
font-size: 11px;
font-weight: 600;
}
.tooltip-trend.positive {
color: #34d399;
}
.tooltip-trend.negative {
color: #ef4444;
}
.tooltip-categories {
display: flex;
flex-direction: column;
gap: 2px;
}
.tooltip-cat-row {
display: flex;
align-items: center;
gap: 6px;
}
.tooltip-cat-label {
font-size: 9px;
color: #94a3b8;
width: 72px;
text-transform: capitalize;
flex-shrink: 0;
}
.tooltip-cat-bar {
flex: 1;
height: 3px;
background: rgba(148, 163, 184, 0.15);
border-radius: 2px;
overflow: hidden;
}
.tooltip-cat-fill {
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, #3b82f6, #60a5fa);
transition: width 0.3s ease;
}
.tooltip-cat-value {
font-size: 9px;
color: #94a3b8;
width: 18px;
text-align: right;
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -1,120 +0,0 @@
<script lang="ts">
import type { CategoryScores } from '../data/types';
let {
categories,
size = 140,
}: {
categories: CategoryScores;
size?: number;
} = $props();
const labels = [
{ key: 'backend', label: 'Backend' },
{ key: 'frontend', label: 'Frontend' },
{ key: 'database', label: 'Database' },
{ key: 'testing', label: 'Testing' },
{ key: 'deployment', label: 'Deploy' },
{ key: 'documentation', label: 'Docs' },
{ key: 'security', label: 'Security' },
{ key: 'ux', label: 'UX' },
] as const;
let center = $derived(size / 2);
let maxR = $derived(size / 2 - 24);
function polarToXY(angle: number, radius: number) {
const rad = ((angle - 90) * Math.PI) / 180;
return {
x: center + Math.cos(rad) * radius,
y: center + Math.sin(rad) * radius,
};
}
// Grid rings
let rings = $derived(
[0.25, 0.5, 0.75, 1].map((pct) => {
const r = maxR * pct;
const points = labels.map((_, i) => {
const angle = (360 / labels.length) * i;
return polarToXY(angle, r);
});
return points.map((p) => `${p.x},${p.y}`).join(' ');
})
);
// Data polygon
let dataPoints = $derived(
labels.map((l, i) => {
const angle = (360 / labels.length) * i;
const value = categories[l.key] / 100;
return polarToXY(angle, maxR * value);
})
);
let dataPolygon = $derived(dataPoints.map((p) => `${p.x},${p.y}`).join(' '));
// Axis lines
let axes = $derived(
labels.map((_, i) => {
const angle = (360 / labels.length) * i;
return polarToXY(angle, maxR);
})
);
// Label positions (slightly outside)
let labelPositions = $derived(
labels.map((l, i) => {
const angle = (360 / labels.length) * i;
const pos = polarToXY(angle, maxR + 16);
return { ...pos, label: l.label, value: categories[l.key] };
})
);
</script>
<svg width={size} height={size} viewBox="0 0 {size} {size}">
<!-- Grid rings -->
{#each rings as ring}
<polygon points={ring} fill="none" stroke="rgba(148, 163, 184, 0.15)" stroke-width="0.5" />
{/each}
<!-- Axis lines -->
{#each axes as axis}
<line
x1={center}
y1={center}
x2={axis.x}
y2={axis.y}
stroke="rgba(148, 163, 184, 0.1)"
stroke-width="0.5"
/>
{/each}
<!-- Data polygon -->
<polygon
points={dataPolygon}
fill="rgba(59, 130, 246, 0.2)"
stroke="rgba(59, 130, 246, 0.8)"
stroke-width="1.5"
/>
<!-- Data points -->
{#each dataPoints as point}
<circle cx={point.x} cy={point.y} r="2.5" fill="#3b82f6" />
{/each}
<!-- Labels -->
{#each labelPositions as lp}
<text
x={lp.x}
y={lp.y}
text-anchor="middle"
dominant-baseline="central"
font-size="8"
font-family="system-ui, sans-serif"
fill="#94a3b8"
>
{lp.label}
</text>
{/each}
</svg>

View file

@ -1,158 +0,0 @@
<script lang="ts">
import type { RiverData } from '../data/types';
import { LAKES } from '../data/layout';
import { water } from '../data/colors';
let { river }: { river: RiverData } = $props();
function lakeName(id: string): string {
const lake = LAKES.find((l) => l.id === id);
return lake?.label || id;
}
let fromLabel = $derived(river.from === 'source' ? 'User Requests' : lakeName(river.from));
let toLabel = $derived(lakeName(river.to));
let speedLabel = $derived(
river.flowSpeed >= 0.8 ? 'Schnell' : river.flowSpeed >= 0.5 ? 'Mittel' : 'Ruhig'
);
let speedColor = $derived(
river.flowSpeed >= 0.8 ? '#34d399' : river.flowSpeed >= 0.5 ? '#fbbf24' : '#60a5fa'
);
</script>
<div class="river-card">
<!-- SVG preview -->
<div class="river-preview">
<svg width="240" height="60" viewBox="0 0 240 60" xmlns="http://www.w3.org/2000/svg">
<!-- River line -->
<path
d="M20,30 Q80,15 120,30 Q160,45 220,30"
fill="none"
stroke={water.river}
stroke-width={river.width * 0.8}
stroke-linecap="round"
opacity="0.7"
/>
<!-- Flow particles -->
<path
d="M20,30 Q80,15 120,30 Q160,45 220,30"
fill="none"
stroke={water.highlight}
stroke-width={river.width * 0.3}
stroke-linecap="round"
stroke-dasharray="8 16"
opacity="0.5"
>
<animate
attributeName="stroke-dashoffset"
values="24;0"
dur="{2 + (1 - river.flowSpeed) * 3}s"
repeatCount="indefinite"
/>
</path>
<!-- Endpoints -->
<circle cx="20" cy="30" r="4" fill={water.deep} />
<circle cx="220" cy="30" r="4" fill={water.deep} />
<!-- Arrow -->
<path
d="M210,26 L220,30 L210,34"
fill="none"
stroke={water.highlight}
stroke-width="1.5"
opacity="0.6"
/>
</svg>
</div>
<div class="river-info">
<div class="river-flow">
<span class="river-from">{fromLabel}</span>
<span class="river-arrow"></span>
<span class="river-to">{toLabel}</span>
</div>
<div class="river-stats">
<span class="river-stat">
<span class="stat-label">Geschwindigkeit</span>
<span class="stat-value" style="color: {speedColor}">{speedLabel}</span>
</span>
<span class="river-stat">
<span class="stat-label">Breite</span>
<span class="stat-value">{river.width}px</span>
</span>
</div>
</div>
</div>
<style>
.river-card {
flex-shrink: 0;
width: 260px;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(8px);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 12px;
overflow: hidden;
transition:
transform 0.2s,
border-color 0.2s;
}
.river-card:hover {
transform: translateY(-3px);
border-color: rgba(59, 130, 246, 0.2);
}
.river-preview {
display: flex;
justify-content: center;
background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
padding: 8px 0;
}
.river-info {
padding: 12px 14px;
}
.river-flow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.river-from,
.river-to {
font-size: 12px;
font-weight: 600;
color: #e2e8f0;
}
.river-arrow {
color: #60a5fa;
font-size: 14px;
}
.river-stats {
display: flex;
gap: 16px;
}
.river-stat {
display: flex;
flex-direction: column;
gap: 1px;
}
.stat-label {
font-size: 9px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
font-size: 12px;
font-weight: 600;
color: #cbd5e1;
}
</style>

View file

@ -1,234 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
let { apps }: { apps: AppData[] } = $props();
// Build timeline data from apps that have trends (previousScore)
// For now we create 2 data points: "before" and "now"
const timePoints = ['Vorher', 'Aktuell'];
const statusColors: Record<string, string> = {
mature: '#34d399',
production: '#60a5fa',
beta: '#fbbf24',
alpha: '#f97316',
prototype: '#94a3b8',
};
// Chart dimensions
const width = 800;
const height = 400;
const padL = 50;
const padR = 30;
const padT = 20;
const padB = 40;
const chartW = width - padL - padR;
const chartH = height - padT - padB;
// Apps sorted by current score
let sorted = $derived(apps.toSorted((a, b) => b.score - a.score));
// Generate line path for each app
function appLine(app: AppData): { path: string; startY: number; endY: number } {
const prev = app.trend !== 0 ? app.score - app.trend : app.score;
const x0 = padL;
const x1 = padL + chartW;
const y0 = padT + chartH - (prev / 100) * chartH;
const y1 = padT + chartH - (app.score / 100) * chartH;
return {
path: `M${x0},${y0} L${x1},${y1}`,
startY: y0,
endY: y1,
};
}
// Grid lines
const gridLines = [0, 25, 50, 75, 100];
let hoveredApp = $state<string | null>(null);
let gainers = $derived(sorted.filter((a) => a.trend > 0).sort((a, b) => b.trend - a.trend));
let avgScore = $derived(Math.round(sorted.reduce((s, a) => s + a.score, 0) / sorted.length));
</script>
<div class="trends-chart">
<svg
viewBox="0 0 {width} {height}"
style="width: 100%; height: auto; max-height: 450px;"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Grid -->
{#each gridLines as val}
{@const y = padT + chartH - (val / 100) * chartH}
<line
x1={padL}
y1={y}
x2={padL + chartW}
y2={y}
stroke="rgba(148, 163, 184, 0.08)"
stroke-width="0.5"
/>
<text
x={padL - 8}
{y}
text-anchor="end"
dominant-baseline="central"
font-size="9"
fill="#64748b"
>
{val}
</text>
{/each}
<!-- Time labels -->
<text x={padL} y={height - 10} text-anchor="start" font-size="10" fill="#64748b">
{timePoints[0]}
</text>
<text x={padL + chartW} y={height - 10} text-anchor="end" font-size="10" fill="#64748b">
{timePoints[1]}
</text>
<!-- Lines for each app -->
{#each sorted as app (app.id)}
{@const line = appLine(app)}
{@const isHovered = hoveredApp === app.id}
{@const color = statusColors[app.status] || '#94a3b8'}
<g opacity={hoveredApp && !isHovered ? 0.15 : 1} style="transition: opacity 0.2s;">
<!-- Line -->
<path
d={line.path}
fill="none"
stroke={color}
stroke-width={isHovered ? 3 : 1.5}
stroke-linecap="round"
/>
<!-- Start dot -->
<circle cx={padL} cy={line.startY} r={isHovered ? 4 : 2.5} fill={color} />
<!-- End dot -->
<circle cx={padL + chartW} cy={line.endY} r={isHovered ? 4 : 2.5} fill={color} />
<!-- Label at end -->
<text
x={padL + chartW + 6}
y={line.endY}
font-size={isHovered ? 11 : 9}
font-weight={isHovered ? 600 : 400}
fill={isHovered ? '#f1f5f9' : '#94a3b8'}
dominant-baseline="central"
>
{app.displayName}
{app.score}
</text>
<!-- Hover area (invisible wide line) -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- This is an SVG hover area for tooltip display, not an interactive control -->
<path
d={line.path}
fill="none"
stroke="transparent"
stroke-width="12"
onmouseenter={() => (hoveredApp = app.id)}
onmouseleave={() => (hoveredApp = null)}
style="cursor: pointer;"
/>
</g>
{/each}
<!-- Trend annotation for apps with big changes -->
{#each sorted.filter((a) => Math.abs(a.trend) >= 10) as app (app.id)}
{@const line = appLine(app)}
{@const midX = padL + chartW * 0.5}
{@const midY = (line.startY + line.endY) / 2}
<text
x={midX}
y={midY - 8}
text-anchor="middle"
font-size="10"
font-weight="600"
fill={app.trend > 0 ? '#34d399' : '#ef4444'}
opacity={hoveredApp && hoveredApp !== app.id ? 0.15 : 0.7}
>
{app.trend > 0 ? '+' : ''}{app.trend}
</text>
{/each}
</svg>
<!-- Summary cards -->
<div class="trend-summary">
{#if gainers.length}
<div class="summary-card">
<span class="summary-label">Grosste Verbesserungen</span>
<div class="summary-items">
{#each gainers as app (app.id)}
<span class="summary-item gain">
{app.displayName} <strong>+{app.trend}</strong>
</span>
{/each}
</div>
</div>
{/if}
<div class="summary-card">
<span class="summary-label">Durchschnitt</span>
<span class="summary-value">{avgScore}</span>
</div>
</div>
</div>
<style>
.trends-chart {
display: flex;
flex-direction: column;
gap: 20px;
}
.trend-summary {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.summary-card {
background: rgba(15, 23, 42, 0.5);
border: 1px solid rgba(148, 163, 184, 0.08);
border-radius: 10px;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.summary-label {
font-size: 10px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
}
.summary-items {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.summary-item {
font-size: 12px;
color: #cbd5e1;
}
.summary-item.gain strong {
color: #34d399;
}
.summary-value {
font-size: 24px;
font-weight: 800;
color: #f1f5f9;
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -1,69 +0,0 @@
<script lang="ts">
import type { RiverData } from '../data/types';
import { water } from '../data/colors';
let { river }: { river: RiverData } = $props();
let duration = $derived(`${3 + (1 - river.flowSpeed) * 4}s`);
let dashLength = $derived(river.width * 1.5);
let gapLength = $derived(river.width * 3);
</script>
<g class="river" data-river={river.id}>
<!-- River bed (wider, darker) -->
<path
d={river.path}
fill="none"
stroke={water.deep}
stroke-width={river.width + 4}
stroke-linecap="round"
stroke-linejoin="round"
opacity="0.3"
/>
<!-- River water -->
<path
d={river.path}
fill="none"
stroke={water.river}
stroke-width={river.width}
stroke-linecap="round"
stroke-linejoin="round"
opacity="0.7"
/>
<!-- Animated flow particles -->
<path
d={river.path}
fill="none"
stroke={water.highlight}
stroke-width={river.width * 0.4}
stroke-linecap="round"
stroke-dasharray="{dashLength} {gapLength}"
opacity="0.4"
>
<animate
attributeName="stroke-dashoffset"
values="{dashLength + gapLength};0"
dur={duration}
repeatCount="indefinite"
/>
</path>
<!-- Surface shimmer -->
<path
d={river.path}
fill="none"
stroke="white"
stroke-width={river.width * 0.2}
stroke-dasharray="2 {gapLength * 1.5}"
opacity="0.2"
>
<animate
attributeName="stroke-dashoffset"
values="{gapLength * 1.5 + 2};0"
dur={duration}
repeatCount="indefinite"
/>
</path>
</g>

View file

@ -22,6 +22,9 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [
'albumItems', // TODO: audit
'albums', // TODO: audit
'articleTags', // FK-only junction into globalTags (articleId, tagId). Tag names live in globalTags.
'articleImportJobs', // Bulk-import job header (counters, status, lease metadata). Pure operational state, no user-typed content. See docs/plans/articles-bulk-import.md.
'articleImportItems', // One row per URL in a bulk job. URL is plaintext by necessity — server-worker reads it without master-key access (same rationale as articles.originalUrl).
'articleExtractPickup', // Short-lived server-write inbox; the client picks up the extracted payload, encrypts it into the articles table, deletes the row. Plaintext by necessity (server has no master key); empty in steady state.
'automations', // TODO: audit
'boardViews', // TODO: audit
'budgets', // TODO: audit

View file

@ -168,11 +168,6 @@ db.version(1).stores({
timeWorldClocks: 'id, sortOrder, timezone',
entryTags: 'id, entryId, tagId, [entryId+tagId]',
// ─── Context (appId: 'context') ───
contextSpaces: 'id, pinned, prefix',
documents: 'id, spaceId, type, pinned, title, [spaceId+type], updatedAt',
documentTags: 'id, documentId, tagId, [documentId+tagId]',
// ─── Questions (appId: 'questions') ───
qCollections: 'id, sortOrder, isDefault',
questions: 'id, collectionId, status, priority, [collectionId+status]',
@ -673,11 +668,10 @@ db.version(30).stores({
_serverIterationExecutions: 'iterationId, missionId, executedAt',
});
// v31 — Rename the legacy `spaceId` field to `contextSpaceId` on four
// v31 — Rename the legacy `spaceId` field to `contextSpaceId` on three
// tables that owned the term before the multi-tenancy Spaces foundation
// arrived (v28):
// - conversations (chat module's reference to a context-space folder)
// - documents (context module's parent context-space)
// - spaceMembers (memoro's members of a context-space)
// - memoSpaces (memoro's memo ↔ context-space join)
//
@ -696,12 +690,11 @@ db.version(30).stores({
db.version(31)
.stores({
conversations: 'id, isArchived, isPinned, contextSpaceId, templateId, updatedAt',
documents: 'id, contextSpaceId, type, pinned, title, [contextSpaceId+type], updatedAt',
spaceMembers: 'id, contextSpaceId, userId',
memoSpaces: 'id, memoId, contextSpaceId',
})
.upgrade(async (tx) => {
const tables = ['conversations', 'documents', 'spaceMembers', 'memoSpaces'] as const;
const tables = ['conversations', 'spaceMembers', 'memoSpaces'] as const;
for (const name of tables) {
await tx
.table(name)
@ -1404,6 +1397,39 @@ db.version(55).upgrade(async (tx) => {
}
});
// v56 — Articles Bulk-Import (docs/plans/articles-bulk-import.md Phase 1).
// Three new tables that ride the standard sync pipeline under the
// articles appId:
//
// articleImportJobs — one row per bulk-import the user kicked off.
// Indexed on `status` for the JobsList tab filter, `[spaceId+status]`
// for the per-Space active-job query the worker projection runs,
// and `_updatedAtIndex` for chronological sort. Lease columns are
// scanned via JS filter — only ~tens of running jobs per user.
// articleImportItems — one row per URL inside a job. `[jobId+state]`
// is the hot index: the JobDetailView range-scans pending+running
// items per job, and the worker pulls "items in state=pending for
// these jobIds". `idx` is plain so the in-list display order
// scrolls without an extra sort key. `state` standalone is used by
// the cross-job retry-failed query.
// articleExtractPickup — short-lived inbox between server-worker
// write and client-pickup-consumer read. `itemId` indexed so the
// consumer can join back to the owning item row. Empty in steady
// state; server-side GC caps it at 24 h.
//
// All three are plaintext (encryption registry: plaintext-allowlist).
// `articleImportItems.url` and `articleExtractPickup.payload` ARE
// user-typed-adjacent content but stay plaintext by necessity — the
// server-side worker reads them without master-key access. Same
// rationale as articles.originalUrl. Once the article is persisted,
// the encrypted copy lives in `articles` and the item carries only an
// articleId pointer.
db.version(56).stores({
articleImportJobs: 'id, status, [spaceId+status], _updatedAtIndex',
articleImportItems: 'id, jobId, [jobId+state], state, idx',
articleExtractPickup: 'id, itemId, _updatedAtIndex',
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -7,8 +7,19 @@
*/
import { db } from '$lib/data/database';
import type { LocalArticle, LocalHighlight, LocalArticleTag } from './types';
import type {
LocalArticle,
LocalArticleExtractPickup,
LocalArticleImportItem,
LocalArticleImportJob,
LocalArticleTag,
LocalHighlight,
} from './types';
export const articleTable = db.table<LocalArticle>('articles');
export const articleHighlightTable = db.table<LocalHighlight>('articleHighlights');
export const articleTagTable = db.table<LocalArticleTag>('articleTags');
export const articleImportJobTable = db.table<LocalArticleImportJob>('articleImportJobs');
export const articleImportItemTable = db.table<LocalArticleImportItem>('articleImportItems');
export const articleExtractPickupTable =
db.table<LocalArticleExtractPickup>('articleExtractPickup');

View file

@ -1,12 +1,19 @@
import type { ModuleConfig } from '$lib/data/module-registry';
/**
* Articles module saved web articles + highlights + tag links.
* Articles module saved web articles + highlights + tag links + bulk-
* import jobs.
*
* `articleTags` is a pure junction into globalTags (the core `tags`
* appId). The junction itself syncs under `articles` appId with its
* owning rows, the same pattern every other tagged module uses
* (noteTags, eventTags, contactTags, placeTags, ).
*
* `articleImportJobs` + `articleImportItems` + `articleExtractPickup`
* implement the durable bulk-import pipeline (docs/plans/articles-bulk-
* import.md). All three sync under the articles appId so multi-device
* progress and server-worker state-transitions ride the standard
* sync_changes channel.
*/
export const articlesModuleConfig: ModuleConfig = {
appId: 'articles',
@ -14,5 +21,8 @@ export const articlesModuleConfig: ModuleConfig = {
{ name: 'articles' },
{ name: 'articleHighlights', syncName: 'highlights' },
{ name: 'articleTags' },
{ name: 'articleImportJobs', syncName: 'importJobs' },
{ name: 'articleImportItems', syncName: 'importItems' },
{ name: 'articleExtractPickup', syncName: 'extractPickup' },
],
};

View file

@ -1,7 +1,7 @@
/**
* Articles module Pocket-style read-it-later.
*
* Three Dexie tables:
* Six Dexie tables:
*
* articles saved URLs + extracted Readability content
* (encrypted: title, excerpt, content, htmlContent,
@ -12,6 +12,23 @@
* articleTags pure junction into globalTags. No user-typed
* content lives here tag names/colors are in
* the global tag system (appId: 'tags').
*
* articleImportJobs Bulk-Import job header. Plaintext: counters,
* status, lease metadata. See
* docs/plans/articles-bulk-import.md.
* articleImportItems One row per URL in a bulk job. URL stays
* plaintext (server-worker reads it without
* master-key access same rationale as
* articles.originalUrl). State machine:
* pending extracting extracted
* (saved | duplicate | consent-wall | error |
* cancelled).
* articleExtractPickup Server-write inbox: the worker drops the
* extracted payload here, the client picks it
* up, runs encryptRecord + articleTable.add,
* then deletes the row. Plaintext by necessity
* (server has no master key); empty in steady
* state.
*/
import type { BaseRecord } from '@mana/local-store';
@ -115,3 +132,124 @@ export interface Highlight {
createdAt: string;
updatedAt: string;
}
// ─── Bulk Import (docs/plans/articles-bulk-import.md) ─────
/**
* Job status drives the index list filter and the JobDetailView's
* action bar. `running` is the only state where the worker actively
* pulls items; `paused` lets the user stop progress without losing the
* remaining queue, `cancelled` is a hard stop with all pending items
* flipped to terminal `cancelled`.
*/
export type ArticleImportJobStatus = 'queued' | 'running' | 'paused' | 'done' | 'cancelled';
/**
* Item state machine. Server-side transitions: pending extracting
* extracted (worker has dropped a pickup row). Client-side transitions:
* extracted saved | duplicate | consent-wall (pickup-consumer
* applied the result). Both sides may transition to error (worker after
* 3 retries, client if encryptRecord/add fails). cancelled is terminal
* and only set when the parent job is cancelled before the item ran.
*/
export type ArticleImportItemState =
| 'pending'
| 'extracting'
| 'extracted'
| 'saved'
| 'duplicate'
| 'consent-wall'
| 'error'
| 'cancelled';
export interface LocalArticleImportJob extends BaseRecord {
totalUrls: number;
status: ArticleImportJobStatus;
/** Worker lease — workerId of the apps/api instance that claimed the job. */
leasedBy: string | null;
/** ISO timestamp; lease is dead once `leasedUntil < now`. */
leasedUntil: string | null;
startedAt: string | null;
finishedAt: string | null;
/** Counters mirror the per-item terminal states. Cache for fast list
* rendering truth lives in the item rows. Worker stamps these on
* each transition. */
savedCount: number;
duplicateCount: number;
errorCount: number;
warningCount: number;
}
export interface LocalArticleImportItem extends BaseRecord {
jobId: string;
/** Original position in the user-provided URL list. Drives display order. */
idx: number;
/** Plaintext server worker reads it without master-key access. Same
* rationale as articles.originalUrl / newsArticles.originalUrl. */
url: string;
state: ArticleImportItemState;
/** Pointer into `articles` table once the article is persisted. */
articleId: string | null;
warning: 'probable_consent_wall' | null;
/** Plaintext technical error message ("502 Bad Gateway", "timeout"). */
error: string | null;
attempts: number;
lastAttemptAt: string | null;
}
/**
* Server client handoff. Lives only between worker-write and
* pickup-consumer-read. Empty in steady state.
*/
export interface LocalArticleExtractPickup extends BaseRecord {
itemId: string;
/** The server's ExtractedArticle JSON, plaintext. Mirrors the shape
* in articles/api.ts but lives here as a structural type so the
* database layer doesn't import the API client. */
payload: {
originalUrl: string;
title: string;
excerpt: string | null;
content: string;
htmlContent: string;
author: string | null;
siteName: string | null;
wordCount: number;
readingTimeMinutes: number;
warning?: 'probable_consent_wall';
};
}
// Public DTOs used by views (livequery converters strip the BaseRecord
// internals + map state to display-friendly shapes).
export interface ArticleImportJob {
id: string;
totalUrls: number;
status: ArticleImportJobStatus;
leasedBy: string | null;
leasedUntil: string | null;
startedAt: string | null;
finishedAt: string | null;
savedCount: number;
duplicateCount: number;
errorCount: number;
warningCount: number;
createdAt: string;
updatedAt: string;
}
export interface ArticleImportItem {
id: string;
jobId: string;
idx: number;
url: string;
state: ArticleImportItemState;
articleId: string | null;
warning: 'probable_consent_wall' | null;
error: string | null;
attempts: number;
lastAttemptAt: string | null;
createdAt: string;
updatedAt: string;
}

View file

@ -1,351 +0,0 @@
<script lang="ts">
import SeenplatteScene from '$lib/components/observatory/SeenplatteScene.svelte';
import PlantCard from '$lib/components/observatory/ui/PlantCard.svelte';
import LakeCard from '$lib/components/observatory/ui/LakeCard.svelte';
import RiverCard from '$lib/components/observatory/ui/RiverCard.svelte';
import Leaderboard from '$lib/components/observatory/ui/Leaderboard.svelte';
import CompareView from '$lib/components/observatory/ui/CompareView.svelte';
import TrendsChart from '$lib/components/observatory/ui/TrendsChart.svelte';
import DetailPanel from '$lib/components/observatory/ui/DetailPanel.svelte';
import { createMockEcosystem } from '$lib/components/observatory/data/mockData';
import { LAKES, RIVERS } from '$lib/components/observatory/data/layout';
import type { AppData } from '$lib/components/observatory/data/types';
import { RoutePage } from '$lib/components/shell';
type Tab = 'scene' | 'plants' | 'lakes' | 'rivers' | 'leaderboard' | 'compare' | 'trends';
let activeTab = $state<Tab>('scene');
let selectedApp = $state<AppData | null>(null);
const apps = createMockEcosystem();
const lakes = LAKES;
const rivers = RIVERS;
// Sort apps by score descending for gallery
const sortedApps = apps.toSorted((a, b) => b.score - a.score);
// Group by status
const matureApps = sortedApps.filter((a) => a.status === 'mature');
const productionApps = sortedApps.filter((a) => a.status === 'production');
const betaApps = sortedApps.filter((a) => a.status === 'beta');
const alphaApps = sortedApps.filter((a) => a.status === 'alpha');
const tabs: { id: Tab; label: string; count?: number }[] = [
{ id: 'scene', label: 'Seenplatte' },
{ id: 'plants', label: 'Pflanzen', count: apps.length },
{ id: 'lakes', label: 'Seen', count: lakes.length },
{ id: 'rivers', label: 'Flusse', count: rivers.length },
{ id: 'leaderboard', label: 'Rangliste' },
{ id: 'compare', label: 'Vergleich' },
{ id: 'trends', label: 'Trends' },
];
</script>
<RoutePage appId="observatory">
<div class="observatory-page">
<!-- Tab bar -->
<div class="tab-bar">
{#each tabs as tab}
<button
type="button"
class="tab-btn"
class:active={activeTab === tab.id}
onclick={() => (activeTab = tab.id)}
>
{tab.label}
{#if tab.count}
<span class="tab-count">{tab.count}</span>
{/if}
</button>
{/each}
</div>
<!-- Tab content -->
{#if activeTab === 'scene'}
<div class="scene-container">
<SeenplatteScene />
</div>
{:else if activeTab === 'plants'}
<div class="gallery-section">
<div class="gallery-header">
<h2 class="gallery-title">Alle Pflanzen</h2>
<p class="gallery-subtitle">
Jede App im Mana-Okosystem als Pflanze — Grosse und Art spiegeln den ManaScore wider
</p>
</div>
{#if matureApps.length}
<div class="gallery-group">
<h3 class="group-label">
<span class="group-dot" style="background: #34d399"></span>
Mature
</h3>
<div class="gallery-scroll">
{#each matureApps as app (app.id)}
<PlantCard {app} onclick={() => (selectedApp = app)} />
{/each}
</div>
</div>
{/if}
{#if productionApps.length}
<div class="gallery-group">
<h3 class="group-label">
<span class="group-dot" style="background: #60a5fa"></span>
Production
</h3>
<div class="gallery-scroll">
{#each productionApps as app (app.id)}
<PlantCard {app} onclick={() => (selectedApp = app)} />
{/each}
</div>
</div>
{/if}
{#if betaApps.length}
<div class="gallery-group">
<h3 class="group-label">
<span class="group-dot" style="background: #fbbf24"></span>
Beta
</h3>
<div class="gallery-scroll">
{#each betaApps as app (app.id)}
<PlantCard {app} onclick={() => (selectedApp = app)} />
{/each}
</div>
</div>
{/if}
{#if alphaApps.length}
<div class="gallery-group">
<h3 class="group-label">
<span class="group-dot" style="background: #f97316"></span>
Alpha
</h3>
<div class="gallery-scroll">
{#each alphaApps as app (app.id)}
<PlantCard {app} onclick={() => (selectedApp = app)} />
{/each}
</div>
</div>
{/if}
</div>
{:else if activeTab === 'lakes'}
<div class="gallery-section">
<div class="gallery-header">
<h2 class="gallery-title">Alle Seen</h2>
<p class="gallery-subtitle">
Infrastruktur-Services als Gewasser — Klarheit und Fullstand zeigen den Systemzustand
</p>
</div>
<div class="gallery-scroll lakes-scroll">
{#each lakes as lake (lake.id)}
<LakeCard {lake} />
{/each}
</div>
</div>
{:else if activeTab === 'rivers'}
<div class="gallery-section">
<div class="gallery-header">
<h2 class="gallery-title">Alle Flusse</h2>
<p class="gallery-subtitle">
Datenstrome zwischen den Seen — Geschwindigkeit und Breite zeigen den Durchsatz
</p>
</div>
<div class="gallery-scroll">
{#each rivers as river (river.id)}
<RiverCard {river} />
{/each}
</div>
</div>
{:else if activeTab === 'leaderboard'}
<div class="gallery-section">
<div class="gallery-header">
<h2 class="gallery-title">Rangliste</h2>
<p class="gallery-subtitle">
Alle Apps sortiert nach Score — klicke auf Spalten zum Sortieren, auf Zeilen fur Details
</p>
</div>
<Leaderboard apps={sortedApps} onselect={(app) => (selectedApp = app)} />
</div>
{:else if activeTab === 'compare'}
<div class="gallery-section">
<div class="gallery-header">
<h2 class="gallery-title">Vergleich</h2>
<p class="gallery-subtitle">
Wahle bis zu 4 Apps und vergleiche ihre Starken und Schwachen direkt
</p>
</div>
<CompareView {apps} />
</div>
{:else if activeTab === 'trends'}
<div class="gallery-section">
<div class="gallery-header">
<h2 class="gallery-title">Trends</h2>
<p class="gallery-subtitle">
Score-Entwicklung aller Apps uber die Zeit — hover uber eine Linie fur Details
</p>
</div>
<TrendsChart {apps} />
</div>
{/if}
</div>
<!-- Detail panel (shared across tabs) -->
<DetailPanel app={selectedApp} onclose={() => (selectedApp = null)} />
</RoutePage>
<style>
.observatory-page {
display: flex;
flex-direction: column;
gap: 0;
}
/* Tab bar */
.tab-bar {
display: flex;
gap: 2px;
background: rgba(15, 23, 42, 0.4);
padding: 4px;
border-radius: 10px;
margin-bottom: 12px;
}
.tab-btn {
flex: 1;
padding: 8px 16px;
border: none;
background: transparent;
color: #94a3b8;
font-size: 13px;
font-weight: 500;
border-radius: 7px;
cursor: pointer;
transition:
background 0.2s,
color 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.tab-btn:hover {
color: #cbd5e1;
background: rgba(148, 163, 184, 0.08);
}
.tab-btn.active {
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
font-weight: 600;
}
.tab-count {
font-size: 10px;
background: rgba(148, 163, 184, 0.15);
padding: 1px 6px;
border-radius: 8px;
font-weight: 600;
}
.tab-btn.active .tab-count {
background: rgba(59, 130, 246, 0.2);
color: #93c5fd;
}
/* Scene tab */
.scene-container {
height: calc(100vh - 14rem);
min-height: 500px;
width: 100%;
}
/* Gallery sections */
.gallery-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.gallery-header {
margin-bottom: 4px;
}
.gallery-title {
font-size: 20px;
font-weight: 700;
color: hsl(var(--color-foreground));
margin: 0 0 4px 0;
}
.gallery-subtitle {
font-size: 13px;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
/* Horizontal scroll container */
.gallery-scroll {
display: flex;
gap: 14px;
overflow-x: auto;
padding-bottom: 12px;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
}
.gallery-scroll > :global(*) {
scroll-snap-align: start;
}
/* Hide scrollbar but keep functionality */
.gallery-scroll::-webkit-scrollbar {
height: 6px;
}
.gallery-scroll::-webkit-scrollbar-track {
background: rgba(148, 163, 184, 0.05);
border-radius: 3px;
}
.gallery-scroll::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.15);
border-radius: 3px;
}
.gallery-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.25);
}
/* Group headers */
.gallery-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.group-label {
font-size: 12px;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0;
display: flex;
align-items: center;
gap: 6px;
}
.group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1,585 @@
# Articles — Bulk URL Import
## Status (2026-04-28)
**Phase 0 — Plan:** in progress.
**Phasen 17:** offen.
## Ziel
User wirft eine Liste von URLs in ein Textfeld (zeilengetrennt), Mana
extrahiert + speichert alle Artikel im Hintergrund. Funktioniert auch wenn
der Tab schließt, das Gerät wechselt, das Netz kurz weg ist. Der Job
überlebt Sessions und ist auf jedem Gerät sichtbar an dem der User
eingeloggt ist.
Heute existiert nur Single-URL-Ingestion (`AddUrlForm`, `QuickAddInput`,
Bookmarklets v1+v2, Share-Target). Alle Pfade rufen am Ende
`articlesStore.saveFromUrl()` oder `saveFromExtracted()` auf.
## Leitsätze
1. **Job-State lebt in der synchronisierten DB**, nicht im Tab. Damit
fallen Tab-Close-Resilienz, Multi-Device-Sicht, Resume-after-Offline
und Audit automatisch ab.
2. **Server macht Extract, Client macht Encrypt.** Das ehrt das At-Rest-
Modell — der Master-Key bleibt clientseitig, der Server sieht den
extrahierten Text nur kurz in einer Pickup-Inbox (gleicher Threat-
Model wie heute schon der `/extract`-Endpoint).
3. **Eine Code-Bahn für jede Ingestion.** Single-URL und Bulk laufen
nach Phase 7 durch denselben Worker — der QuickAdd-Pfad legt unter
der Haube auch einen 1-URL-Job an.
4. **Soft → Hard.** Schema- und Semantik-Migrationen kommen in zwei
Commits: erst tolerant zu alten Rows, dann hartes Cleanup.
## Architektur
```
┌──────────────────────────────────────────┐
│ /articles/import (List + JobDetail) │
│ pure liveQuery-View, UI macht keine │
│ Job-Logik selbst │
└─────────────────┬────────────────────────┘
│ liveQuery
┌────────────────────────────────────────────────────────┐
│ Dexie + mana-sync (articles appId) │
│ │
│ articleImportJobs │
│ id, spaceId, totalUrls, status, leasedBy, │
│ leasedUntil, savedCount, duplicateCount, │
│ errorCount, warningCount, finishedAt │
│ │
│ articleImportItems │
│ id, jobId, spaceId, idx, url, state, articleId, │
│ warning, error, attempts, lastAttemptAt │
│ │
│ articleExtractPickup (kurzlebige Inbox) │
│ id, itemId, payload (extracted), createdAt │
└─────────────────┬───────────────────────┬──────────────┘
│ pending items │ pickup rows
▼ ▼
┌─────────────────────────────┐ ┌──────────────────────────┐
│ apps/api Extract-Worker │ │ Client Pickup-Consumer │
│ • snapshot der Items │ │ • liveQuery auf Pickup │
│ • Lease + Heartbeat │ │ • encryptRecord │
│ • Concurrency 3 / User │ │ • articleTable.add() │
│ • shared-rss extractFromUrl│ │ • item.state='saved' │
│ • schreibt Pickup-Row │ │ • pickup.delete() │
│ • setzt Item-State │ │ │
└─────────────────────────────┘ └──────────────────────────┘
```
## Datenmodell
### `articleImportJobs` (synced, articles appId)
```ts
interface LocalArticleImportJob extends BaseRecord {
totalUrls: number;
status: 'queued' | 'running' | 'paused' | 'done' | 'cancelled';
/** Worker-Lease — verhindert dass mehrere Worker denselben Job ziehen.
* Server-Worker stempelt seine workerId beim Claim, erneuert die
* leasedUntil per Heartbeat. Lease-Ablauf > 60s = Job ist verfügbar. */
leasedBy: string | null;
leasedUntil: string | null;
startedAt: string | null;
finishedAt: string | null;
/** Counters werden vom Server beim Item-Übergang in einen Terminal-State
* inkrementiert. Pure Bookkeeping — Truth liegt in den Item-Rows, das
* hier ist die Cache-Spalte für die Liste. */
savedCount: number;
duplicateCount: number;
errorCount: number;
warningCount: number;
}
```
### `articleImportItems` (synced, articles appId)
```ts
type ImportItemState =
| 'pending' // wartet auf den Worker
| 'extracting' // Worker hat geclaimed
| 'extracted' // Pickup-Row liegt für den Client bereit
| 'saved' // im Article-Table angekommen
| 'duplicate' // Article mit dieser URL gabs schon
| 'consent-wall' // gespeichert, aber Cookie-Wand erkannt
| 'error' // X Versuche fehlgeschlagen
| 'cancelled'; // Job abgebrochen vor Verarbeitung
interface LocalArticleImportItem extends BaseRecord {
jobId: string;
idx: number; // Reihenfolge aus der User-Eingabe
url: string; // PLAINTEXT — Server muss lesen können
state: ImportItemState;
articleId: string | null; // bei saved/duplicate gesetzt
warning: 'probable_consent_wall' | null;
error: string | null;
attempts: number;
lastAttemptAt: string | null;
}
```
**`url` bleibt bewusst plaintext** — der Server-Worker liest sie aus
`sync_changes` und kann nicht entschlüsseln. Gleiche Begründung wie bei
`articles.originalUrl` / `newsArticles.originalUrl` / `links.originalUrl`.
`error` bleibt plaintext, weil Fehlertexte technisch sind ("502 Bad
Gateway") und keinen User-Inhalt enthalten.
### `articleExtractPickup` (synced, articles appId, kurzlebig)
```ts
interface LocalArticleExtractPickup extends BaseRecord {
itemId: string; // Pointer zum Item — auch dessen jobId
payload: ExtractedArticle; // PLAINTEXT — Server hat das eh
createdAt: string;
}
```
Inbox-Tabelle. Server schreibt rein, Client liest und löscht. Im
Steady-State leer. TTL serverseitig: 24 h, dann GC.
**Warum eine eigene Tabelle statt direkt `articles` schreiben?** Der
Server hat keinen Master-Key — er kann den Article nicht verschlüsseln.
Pickup ist die Übergabe-Pufferzone, der Client holt sie ab und ruft die
existierende `saveFromExtracted()` auf, die `encryptRecord()` triggert.
### Crypto-Registry
```ts
// articleImportJobs: keine User-typed Inhalte → plaintext-allowlist
// articleImportItems: url + error sind plaintext, sonst nichts schützenswert → plaintext-allowlist
// articleExtractPickup: payload wird gleich nach Apply gelöscht → plaintext-allowlist
```
Alle drei landen auf der `plaintext-allowlist.ts`, nicht in
`ENCRYPTION_REGISTRY`. Items und Job-Rows enthalten keine User-typed-
Felder die nicht eh schon plaintext bleiben müssten (URL fürs Routing,
Counters, Foreign Keys). Der eigentliche Article-Inhalt wandert wie
bisher verschlüsselt in `articles`.
### Module-Config + Sync
`modules/articles/module.config.ts` bekommt drei neue Tabellen:
```ts
tables: [
{ name: 'articles' },
{ name: 'articleHighlights', syncName: 'highlights' },
{ name: 'articleTags' },
{ name: 'articleImportJobs', syncName: 'importJobs' },
{ name: 'articleImportItems', syncName: 'importItems' },
{ name: 'articleExtractPickup', syncName: 'extractPickup' },
]
```
Damit gehen sie automatisch durch den Standard-Sync-Pfad, RLS,
field-level LWW. Keine neue Sync-Infrastruktur.
## Server-Worker
### Wo
**`apps/api/src/modules/articles/import-worker.ts`**, gestartet aus
`apps/api/src/index.ts` neben den Routes. Nicht in `services/mana-ai`
(falscher Scope) und nicht in `services/mana-research` (Provider-
Orchestrierung, kein Persistenz-Worker).
### Konzept
Standard-Pattern aus `services/mana-ai`:
1. **Snapshot-Projektion** — eine kleine Tabelle in `mana_platform.articles_imports`-Schema
die `sync_changes` für `appId='articles'` und `tableName ∈ {articleImportJobs, articleImportItems}`
zu Live-Records faltet (field-level LWW). Refreshed sich pro Tick.
2. **Tick alle 2 s.** Liest die Snapshot, sucht:
- Jobs mit `status='running'` und (`leasedBy=null` OR `leasedUntil < now`)
- dazu Items mit `state='pending'` für diese Jobs
3. **Lease**`leasedBy` auf eigene `workerId` setzen, `leasedUntil = now + 60s`. Schreiben als
`sync_changes`-Row mit `actor=system`, `origin=system`, `source='articles-import-worker'`.
4. **Concurrency 3 pro Job** — pro Tick max 3 Items in `state='extracting'` schalten,
`extractFromUrl()` aus `@mana/shared-rss` aufrufen.
5. **Pickup-Write** — bei Erfolg: `articleExtractPickup`-Row schreiben +
Item-State auf `extracted`. Bei Fehler: `attempts += 1`, wenn `attempts >= 3`
`state='error'`, sonst zurück auf `pending`.
6. **Job-Completion** — wenn alle Items eines Jobs in einem Terminal-State sind
(`saved | duplicate | consent-wall | error | cancelled`), setze
`job.status='done'` + `finishedAt`. Counter-Spalten gleich mit aktualisieren.
7. **Heartbeat** — solange Items `extracting`, alle 30 s `leasedUntil` erneuern.
### Single-Instance-Garantie
`pg_advisory_lock(<key>)` über die Worker-Loop. Falls apps/api in mehreren
Instanzen läuft, nimmt nur eine den Lock und tickt. Andere idlen.
### Counters: woher
Worker tracked Item-Übergänge und stempelt:
- `pending → extracted`: keine Counter-Änderung
- `extracted → saved` (Client signalisiert): `savedCount += 1`
- `extracted → duplicate` (Client signalisiert): `duplicateCount += 1`
- `extracted → consent-wall` (Client signalisiert): `warningCount += 1`
- jeder Übergang → `error`: `errorCount += 1`
Counter-Updates gehen als normale `articleImportJobs.update` durch
`sync_changes`, RLS-correct.
### Server-side Cleanup
Stündlicher GC-Job:
- Pickup-Rows älter 24 h löschen (Sicherheits-Cap)
- Jobs mit `status='done' AND finishedAt < now - 30d` archivieren
(späteres Polish — erst mal nur Cap)
## Client-Pickup-Consumer
`apps/mana/apps/web/src/lib/modules/articles/consume-pickup.ts`,
gestartet aus `data-layer-listeners.ts` zusammen mit den anderen
Listener-Wirings.
Logik:
```ts
liveQuery(() => articleExtractPickup
.filter(r => !r.deletedAt)
.toArray()
).subscribe(rows => {
for (const row of rows) {
void consumeOne(row);
}
});
async function consumeOne(row: LocalArticleExtractPickup) {
// Re-entrancy guard via in-memory Set so multiple liveQuery ticks
// don't race the same row.
if (inFlight.has(row.id)) return;
inFlight.add(row.id);
try {
const item = await articleImportItemTable.get(row.itemId);
if (!item || item.state !== 'extracted') {
await articleExtractPickupTable.delete(row.id); // Stale row
return;
}
// Dedupe-Check für den Fall dass der User die URL parallel
// single-saved hat während der Job lief.
const existing = await articlesStore.findByUrl(row.payload.originalUrl);
if (existing) {
await articleImportItemTable.update(item.id, {
state: 'duplicate',
articleId: existing.id,
});
await articleExtractPickupTable.delete(row.id);
return;
}
const article = await articlesStore.saveFromExtracted(row.payload);
const nextState: ImportItemState =
row.payload.warning === 'probable_consent_wall'
? 'consent-wall'
: 'saved';
await articleImportItemTable.update(item.id, {
state: nextState,
articleId: article.id,
warning: row.payload.warning ?? null,
});
await articleExtractPickupTable.delete(row.id);
} finally {
inFlight.delete(row.id);
}
}
```
Multi-Tab: alle Tabs sehen Pickup-Rows. Web-Lock `mana:articles:pickup`
sorgt dafür dass nur ein Tab gleichzeitig konsumiert. Andere Tabs sehen
die liveQuery, der Lock-halter pickt ab.
## Store-API
`modules/articles/stores/imports.svelte.ts` — neue Datei.
```ts
export const articleImportsStore = {
/** Erzeugt Job + N Items in einem Dexie bulkAdd, returns jobId. */
async createJob(urls: string[]): Promise<string> { … },
async pauseJob(jobId: string): Promise<void> { … },
async resumeJob(jobId: string): Promise<void> { … },
async cancelJob(jobId: string): Promise<void> { … },
/** Setzt alle Error-Items eines Jobs zurück auf pending. */
async retryFailed(jobId: string): Promise<void> { … },
/** Soft-Delete des Jobs + aller Items. Article-Rows bleiben. */
async deleteJob(jobId: string): Promise<void> { … },
};
```
`saveFromExtracted` in `modules/articles/stores/articles.svelte.ts`
bleibt der gemeinsame Kern — der Pickup-Consumer ruft sie genauso auf wie
der existierende Single-URL-Pfad.
URL-Parser steht im Store, nicht im Component:
```ts
export function parseUrls(raw: string): {
valid: string[];
invalid: string[];
duplicates: string[];
} { … }
```
Pure Funktion, unit-testbar.
## UI
### `/articles/import` — Index + Eingabe (`+page.svelte`)
- `<BulkImportForm>` Komponente mit `<textarea>`, Live-Validierung als
`$derived` über `parseUrls()`.
- Counter „X gültig · Y ungültig · Z Duplikate" über dem Submit-Button.
- Submit → `articleImportsStore.createJob(urls)` → goto `/articles/import/[jobId]`.
- Unter dem Form: `<JobsList>` mit aktiven + abgeschlossenen Jobs der
letzten 30 Tage. Each row: Status-Pill, Counter, Click → Detail.
### `/articles/import/[jobId]` — JobDetailView
- Header: Job-Status, Fortschrittsbalken, Counter („3 / 12 — 1 Duplikat,
1 Warnung").
- Action-Bar: Pause / Fortsetzen / Abbrechen / „Fehler erneut versuchen"
(nur wenn `errorCount > 0`).
- Liste der Items (virtuelle Scroll für > 100 Einträge): URL +
State-Pill + Title (sobald gespeichert) + Action-Link
(Öffnen / Erneut versuchen / Fehler-Detail-Tooltip).
### Verlinkung
- `AddUrlForm` (`/articles/add`) bekommt unter dem Input einen kleinen
Link „Mehrere URLs auf einmal? → Bulk-Import".
- Wenn der User in `AddUrlForm` einen Mehrzeiler einfügt, schlägt der
Form vor: „4 URLs erkannt — als Bulk importieren?" → routet auf
`/articles/import` mit pre-fill via `sessionStorage`.
- `/articles/settings` bekommt eine vierte Karte „Mehrere URLs
importieren" mit Link auf `/articles/import`.
## Domain-Events
Zwei neue Events in `data/events`:
```ts
type ArticleImportStarted = {
type: 'ArticleImportStarted';
appId: 'articles';
collection: 'articleImportJobs';
recordId: string;
payload: { totalUrls: number };
};
type ArticleImportFinished = {
type: 'ArticleImportFinished';
appId: 'articles';
collection: 'articleImportJobs';
recordId: string;
payload: {
totalUrls: number;
savedCount: number;
duplicateCount: number;
errorCount: number;
warningCount: number;
};
};
```
Activity-Modul + Recap-Aggregator picken sie automatisch auf
(über das bestehende Event-Bus-Pattern).
## AI-Tool
Neuer Eintrag im `AI_TOOL_CATALOG` (`packages/shared-ai/src/tools/schemas.ts`):
```ts
{
name: 'import_articles_from_urls',
module: 'articles',
policy: 'auto', // Deterministisch, kein per-Article-Approval
schema: z.object({
urls: z.array(z.string().url()).min(1).max(50),
}),
describe: 'Ein Bulk-Import-Job für Artikel-URLs. Returns jobId zum Tracken.',
}
```
`modules/articles/tools.ts` registriert die `execute`-Function: ruft
`articleImportsStore.createJob(urls)` auf, returns `{ jobId, totalUrls }`.
`save_article` (single-URL, propose) bleibt für „bitte speichere DIESEN
Artikel" Befehle. `import_articles_from_urls` ist für Listen.
## Phasen
### Phase 1 — Datenmodell *(soft)*
1. Dexie-Version v56 (next free): `articleImportJobs`, `articleImportItems`,
`articleExtractPickup` declaren mit Indexen:
- `articleImportJobs: 'id, status, [spaceId+status], createdAt'`
- `articleImportItems: 'id, jobId, [jobId+state], idx, state'`
- `articleExtractPickup: 'id, itemId, createdAt'`
2. Types in `modules/articles/types.ts` ergänzen.
3. `module.config.ts` um die drei Tabellen erweitern.
4. `collections.ts` um Table-Refs erweitern.
5. Plaintext-Allowlist-Eintrag für alle drei Tabellen.
6. **Smoke**: `pnpm check:crypto` + `pnpm validate:all` grün.
Acceptance: Schema lädt, kein neuer Daten-Pfad aktiv. Bestehender
Single-URL-Pfad läuft weiter.
### Phase 2 — Server-Worker
1. `apps/api/src/modules/articles/import-worker.ts` mit:
- Snapshot-Projektion (Mirror von `mana-ai/db/missions-projection.ts`)
- Tick-Loop, advisory-lock, Lease/Heartbeat
- `extractFromUrl()` Aufruf, Pickup-Write, State-Transitions
2. Migration `apps/api/drizzle/articles/<n>-imports-snapshot.sql`:
- Schema `articles_imports`
- Snapshot-Tabellen für jobs + items
- GC-Cron-Definition
3. Boot in `apps/api/src/index.ts`:
`import { startArticleImportWorker } from './modules/articles/import-worker';`
`startArticleImportWorker();`
Acceptance: Manuelles Insert eines Job + Items via SQL → Worker
extracted → Pickup-Row erscheint → Item-State `extracted`.
### Phase 3 — Client-Pickup-Consumer
1. `modules/articles/consume-pickup.ts` (s.o.).
2. `data/data-layer-listeners.ts` registriert den Consumer beim Boot.
3. Web-Lock `mana:articles:pickup` für Multi-Tab-Koordination.
4. Re-entrancy-Guard via in-memory Set.
Acceptance: Pickup-Row aus Phase 2 wird konsumiert, Article landet
verschlüsselt in `articles`, Item geht auf `saved`, Pickup-Row gelöscht.
### Phase 4 — Store-API
1. `modules/articles/stores/imports.svelte.ts` mit allen Methoden.
2. `parseUrls` als pure Funktion + Unit-Tests.
3. `saveFromExtracted` bleibt unverändert — wird re-used.
Acceptance: `articleImportsStore.createJob(['url1', 'url2'])` erzeugt
Job + 2 Items, Worker zieht sie, beide landen in `articles`.
### Phase 5 — UI
1. `/articles/import/+page.svelte` mit Form + JobsList.
2. `/articles/import/[jobId]/+page.svelte` mit JobDetailView.
3. `BulkImportForm.svelte`, `JobsList.svelte`, `JobDetailView.svelte`,
`ImportItemRow.svelte` (alles in `modules/articles/components/`).
4. Verlinkung in `AddUrlForm`, `/articles/settings`.
5. Smart-Detect-Hint in `AddUrlForm` für Mehrzeiler-Paste.
6. i18n-Keys in `de.json` + `en.json` (fr/it/es konsistent mit anderen
Articles-Strings).
Acceptance: User pasted 5 URLs in `<textarea>`, klickt „Importieren",
sieht Detail-View mit Live-Updates, kann Pause/Fortsetzen drücken.
### Phase 6 — Domain-Events + AI-Tool
1. `ArticleImportStarted` + `ArticleImportFinished` in `data/events/types.ts`.
2. Worker emittet `Started` beim ersten Item-Claim, `Finished` beim
Job-Completion.
3. `import_articles_from_urls` in `AI_TOOL_CATALOG` + `tools.ts`.
Acceptance: Activity-Modul zeigt beide Events, AI-Tool funktioniert
in einer Mission.
### Phase 7 — Konvergenz *(optional, nach Soak)*
1. `QuickAddInput` + `AddUrlForm` legen unter der Haube auch einen Job
an (1 Item) statt direkt zu speichern.
2. Eine einzige Code-Bahn für jede Ingestion.
3. Hard-Cleanup: `saveFromUrl` entfernen, Aufrufer auf
`articleImportsStore.createJob([url])` migrieren.
4. Single-URL-Pfad navigiert zum Reader sobald der Pickup-Consumer das
`saved`-Event meldet (gleicher Code-Pfad, nur eine UI-Reaktion).
Acceptance: Heart-of-the-app smoke test — neuer User → URL pasten in
QuickAddInput → Reader öffnet sich. Performance vergleichbar (max
+200 ms Latenz akzeptabel, das ist der Worker-Tick).
## Tests
### Phase 1
- `parseUrls` — gültige / ungültige / Duplikate / Whitespace-Varianten.
### Phase 2 (Server)
- Worker pickt nur einen Job pro Lease — zweiter Worker-Stub kommt nicht ran.
- Lease-Renewal — Worker verlängert während Extracting.
- Lease-Expiry — toter Worker, Job wird wieder available.
- Item-Retry — `attempts < 3` schickt zurück auf pending, danach error.
### Phase 3 (Client)
- Pickup → encryptRecord → article.add. Mit `fake-indexeddb`.
- Multi-Tab Web-Lock — nur ein Tab konsumiert.
- Stale Pickup (Item nicht mehr `extracted`) wird ignoriert + gelöscht.
- Dedupe-Race: User hat URL parallel single-saved → Item wird `duplicate`.
### Phase 4 (Store)
- Full lifecycle: createJob → 3 Items → Worker-Mock → 2 saved + 1 error.
- retryFailed setzt nur Error-Items zurück.
- cancelJob setzt alle pending-Items auf `cancelled`.
### Phase 5 (UI)
- E2E Playwright: Bulk-Import mit 5 URLs, alle landen in der Article-
List. Pause-Button stoppt Worker (in Test-Env mit Fake-Worker-Hook).
### Phase 7 (Konvergenz)
- QuickAddInput-Pfad geht durch den Worker, Performance-Smoke (Latenz
vom Klick bis Reader < 5 s im Local-Dev).
## Bekannte Edge-Cases
1. **Worst-Case-Dauer**: 50 URLs × ~25 s Server-Extract / Concurrency 3
≈ 7 min. UI muss das ehrlich anzeigen. „Im Hintergrund — du kannst
weitermachen."
2. **Consent-Wall im Bulk**: Server flagged `warning`, Item landet auf
`consent-wall`, JobDetailView zeigt Hinweis „N Artikel mit Cookie-
Wand — mit Bookmarklet erneut speichern?". Bulk-Retry dieser Items
ist ein späteres Polish, kein MVP-Blocker.
3. **Eingabe-Duplikate**: `parseUrls` dedupliziert vor Job-Erzeugung.
4. **Bestehende Articles**: Pickup-Consumer prüft per `findByUrl` vor
`saveFromExtracted` — falls in der Zwischenzeit single-saved, geht
das Item auf `duplicate`.
5. **Worker-Crash mid-Item**: `state='extracting'` mit abgelaufener
Lease wird vom nächsten Tick zurück auf `pending` gesetzt.
6. **Job-Cancel während Extract läuft**: Worker prüft pro Item den
Job-Status vor dem Pickup-Write. Bei `status='cancelled'`
Item-State auf `cancelled`, kein Pickup.
7. **Ratelimit auf `extractFromUrl`**: Der Server hat Rate-Limits pro
User auf `/api/v1/articles/extract` (200/min). Worker geht nicht
über HTTP — er ruft `extractFromUrl()` direkt aus dem Modul. Kein
Rate-Limit-Konflikt.
8. **Job-Liste wird zu lang**: Soft-Delete-on-30-Days-old in Phase 7-Polish.
## Was bewusst NICHT im Scope ist
- Cross-User-Resharing von Job-Definitionen (kein Use-Case)
- Server-side Re-Extraction (für Re-Index nach Readability-Updates) —
separater Plan
- Bulk-Tagging im selben Schritt — der User taggt nach dem Import, nicht
während
- Import aus Pocket / Instapaper / Raindrop-Backup-Dateien — eigenes
Modul-Feature, später
## Reference Implementations im Repo
- Server-Worker-Pattern: `services/mana-ai/src/runner/`,
`services/mana-ai/src/db/missions-projection.ts`
- Snapshot-Projektion + Advisory-Lock:
`services/mana-ai/src/db/snapshot-refresh.ts`
- Singleton-Bootstrap-via-sync_changes (Server-Write mit system actor):
`services/mana-auth/src/services/bootstrap-singletons.ts`
- Local-only Listener-Wiring: `apps/mana/apps/web/src/lib/data/data-layer-listeners.ts`
- liveQuery-Driven UI: `apps/mana/apps/web/src/lib/modules/ai-missions/`