plant: private Cargill Gouda tracker (/trackers/cargill)
All checks were successful
Deploy production / deploy (push) Successful in 1m19s

Owner-only tracker for learning the Cargill Bioindustrial plant in Gouda:
schema plant (entries, links with confidence, pending refs), plant.apply_entries
bulk import, overview with clusters and flow diagram, kind tabs, glossary,
questions, detail pages and owner inline editing. Docs in docs/plant-tracker.md.
This commit is contained in:
2026-10-26 13:42:37 +00:00
parent fda21554cc
commit e24ee7f95d
26 changed files with 48481 additions and 3 deletions

81
docs/plant-tracker.md Normal file
View File

@@ -0,0 +1,81 @@
# Plant tracker (`/trackers/cargill`)
A private (owner-only) knowledge base about the Cargill Bioindustrial plant in Gouda:
departments, products, process steps, equipment, glossary terms, questions, notes and roles
(never colleague names). Logged-out visitors get a 404 on the page, its data and its actions
(`ownerOnlyPrefixes` in `src/lib/server/owner.ts`).
## Schema `plant` (migration `drizzle/20261026133106_plant`)
- `plant.entries`: `kind`, `slug` (unique per kind), `title`, `summary`, `body` (markdown),
`parent_id` (any kind, e.g. a note under a note), `tags`, `sort_order`, `source`,
`confidence` (`confirmed` | `assumed` | `open`), `answered_at` (questions), timestamps.
- `plant.links`: `from_id`, `to_id`, `relation` (`feeds_into`, `made_in`, `uses`, `part_of`,
`related`), optional `label`, `dashed`, `confidence` (default `confirmed`; `assumed` or
`open` links show dashed in the flow and with a "?" badge on entry pages).
- `plant.pending`: parents and links whose target doesn't exist yet. Every `apply_entries` run
retries them, so a later batch that adds the target resolves them. Entry pages list them as
"pending".
Kinds: `department`, `product`, `process_step`, `equipment`, `glossary`, `question`, `note`,
`person_role`.
How tags drive the UI:
- `cluster` (departments): a section in the departments tab and a card in the overview, with
every unit below it (units with `parent: "cluster-ea"`, and their own units, like MP1's).
- `inactive`: dimmed everywhere; the "hide inactive" switch hides them (off by default).
- `overview` (notes): shown at the top of the overview.
## Bulk import: `plant.apply_entries(jsonb)`
Upserts by `(kind, slug)`, then sets parents and links by slug. Run it as the app user or the
owner; for big files pipe SQL through stdin, e.g.
`select plant.apply_entries($doc$ { ... } $doc$::jsonb);`
The argument is either an object `{ "source": ..., "entries": [...], "links": [...],
"delete": [...] }` or a bare array of entries.
```json
{
"source": "Walter White, batch 5 (2026-10-26)",
"entries": [
{
"kind": "department",
"slug": "414-kolom-1",
"title": "414 Kolom 1",
"summary": "Fat splitting column, continuous",
"body": "In and out:\n- In: feed, water and high-pressure steam.\n- Out: fatty acid and glycerine water.",
"parent": "cluster-dim",
"tags": ["splitting", "continuous"],
"confidence": "confirmed",
"links": [{ "to": "splitting", "relation": "uses", "label": "optional" }]
},
{ "kind": "question", "slug": "rndm", "title": "What is RNDM?", "tags": ["answered"] }
]
}
```
- Required: `kind`, plus `slug` or a `title` to slugify; new entries need a `title`.
- Optional: `summary`, `body`, `parent` (alias `parent_slug`; `null` clears it), `tags`,
`confidence` (default `assumed`, `open` for questions), `sort_order`, `source`, `answered`,
`replace_links`, `replace_tags`, `body_mode` (`"append"`), `links` (`to`, `relation`,
`label`, `dashed`, `confidence`, `remove`).
- Updating an existing entry:
- Keys that are left out keep their current value. An empty `summary` or `body` also keeps
the current text. With `"body_mode": "append"` the new body is added below the current
one (skipped when it is already there) instead of replacing it.
- `tags` are merged into the existing tags, unless the entry sets
`"confidence": "confirmed"` or `"replace_tags": true`, which replace them.
- The top-level `source` is only the default for new entries; pass `source` on an entry to
overwrite it.
- A question tagged `answered` (or with `"answered": true`) is marked answered; the tag is
dropped.
- References (`parent`, `to`, `from`) are a slug or `kind:slug`. A bare slug used by several
kinds resolves in this order: process_step, product, department, equipment, note,
question, person_role, glossary (so `splitting` is the process step).
- Unknown references never abort the import: they are listed in `unresolved` and kept in
`plant.pending` until their target exists.
Returns `{ inserted, updated, linked, unlinked, deleted, unresolved: [{from, field, to}],
resolved_pending, pending, entries: [{id, kind, slug, action}] }`.

View File

@@ -36,7 +36,8 @@ export default defineConfig({
'movies', 'movies',
'worldtodo', 'worldtodo',
'notes', 'notes',
'math' 'math',
'plant'
], ],
migrations: { schema: 'drizzle', table: '__drizzle_migrations' }, migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
verbose: true, verbose: true,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -314,6 +314,34 @@ try {
const [{ n: mathChapters }] = await client`select count(*)::int as n from math.chapters`; const [{ n: mathChapters }] = await client`select count(*)::int as n from math.chapters`;
if (mathChapters !== 100) if (mathChapters !== 100)
throw new Error(`math.chapters has ${mathChapters} rows, expected the 100 Napkin chapters`); throw new Error(`math.chapters has ${mathChapters} rows, expected the 100 Napkin chapters`);
// Cargill plant tracker (owner-only): app role read/write, personal_ro none, and the app can run
// plant.apply_entries (the bulk import). 2 = entries, links; the migration seeds the factory guide.
const PLANT_TABLES = 3;
const [plant] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'plant', 'usage') as public_usage,
has_function_privilege('plant.apply_entries(jsonb)', 'execute') as can_apply
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'plant' and c.relkind = 'r'
`;
if (
Number(plant.tables) !== PLANT_TABLES ||
Number(plant.app_rw) !== PLANT_TABLES ||
Number(plant.ro_read) !== 0 ||
plant.public_usage ||
!plant.can_apply
)
throw new Error(`plant schema tables/grants are wrong: ${JSON.stringify(plant)}`);
const [{ n: plantEntries }] = await client`select count(*)::int as n from plant.entries`;
if (!plantEntries) throw new Error('plant.entries is empty, expected the seeded factory guide');
// Energy tracker reads the ned-collector tables (SELECT only). // Energy tracker reads the ned-collector tables (SELECT only).
const [energy] = await client` const [energy] = await client`
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
@@ -323,7 +351,7 @@ try {
if (!Number(energy.hours)) if (!Number(energy.hours))
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days'); console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
console.log( console.log(
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, worldtodo: ${worldtodo.tables} tables (${worldtodoTasks} tasks), notes: ${notes.tables} tables, math: ${math.tables} tables (${mathChapters} chapters), app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}` `health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, worldtodo: ${worldtodo.tables} tables (${worldtodoTasks} tasks), notes: ${notes.tables} tables, math: ${math.tables} tables (${mathChapters} chapters), plant: ${plant.tables} tables (${plantEntries} entries), app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
); );
const [catalog] = await client` const [catalog] = await client`
select select

79
src/lib/plant.test.ts Normal file
View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import {
KIND_TAB,
PLANT_KINDS,
entryHref,
kindFromSegment,
kindFromTab,
layoutFlow,
matches,
slugify,
tabHref
} from './plant';
describe('plant tracker', () => {
it('lays the flow out top to bottom, ignoring dashed loops', () => {
// oil → clean → split → col → dimer, dimer ⇢ col (dashed), clean ⇢ ester, col → ester
const nodes = [1, 2, 3, 4, 5, 6].map((id) => ({ id, sort: id }));
const edges = [
{ from: 1, to: 2, dashed: false },
{ from: 2, to: 3, dashed: false },
{ from: 3, to: 4, dashed: false },
{ from: 4, to: 5, dashed: false },
{ from: 5, to: 4, dashed: true },
{ from: 2, to: 6, dashed: true },
{ from: 4, to: 6, dashed: false }
];
const { placed, width, height } = layoutFlow(nodes, edges);
const layer = Object.fromEntries(placed.map((p) => [p.id, p.layer]));
expect(layer).toEqual({ 1: 0, 2: 1, 3: 2, 4: 3, 5: 4, 6: 4 });
expect(width).toBeGreaterThan(0);
expect(height).toBeGreaterThan(0);
});
it('survives a solid cycle', () => {
const { placed } = layoutFlow(
[
{ id: 1, sort: 0 },
{ id: 2, sort: 1 }
],
[
{ from: 1, to: 2, dashed: false },
{ from: 2, to: 1, dashed: false }
]
);
expect(placed.map((p) => p.layer)).toEqual([0, 1]);
});
it('maps kinds to URLs and back', () => {
expect(entryHref({ kind: 'process_step', slug: 'splitting' })).toBe(
'/trackers/cargill/process-step/splitting'
);
expect(kindFromSegment('process-step')).toBe('process_step');
expect(kindFromSegment('nope')).toBeNull();
expect(slugify('Lab (QC) – Ëster')).toBe('lab-qc-ester');
});
});
describe('tabs and search', () => {
it('maps kinds to tabs and back', () => {
for (const k of PLANT_KINDS) expect(kindFromTab(KIND_TAB[k])).toBe(k);
expect(kindFromTab('nope')).toBeNull();
expect(kindFromTab(null)).toBeNull();
expect(tabHref('process_step')).toBe('/trackers/cargill?tab=process');
expect(tabHref(null)).toBe('/trackers/cargill');
});
it('matches all words, ignoring case and accents', () => {
const e = {
title: 'Dimer acids',
slug: 'dimer-acids',
summary: 'Twee vetzuren',
body: null,
tags: ['product']
};
expect(matches(e, 'DIMER twee')).toBe(true);
expect(matches(e, 'dimer ester')).toBe(false);
expect(matches({ ...e, title: 'Crème' }, 'creme')).toBe(true);
expect(matches(e, ' ')).toBe(true);
});
});

184
src/lib/plant.ts Normal file
View File

@@ -0,0 +1,184 @@
// Cargill plant tracker (/trackers/cargill): what the Cargill Bioindustrial plant does.
// Shared by the server (loads, validation) and the pages. Owner-only: the whole path is in
// ownerOnlyPrefixes (#lib/server/owner), so everyone else gets a 404.
/** Entry kinds (kept in sync with the entries_kind_check constraint in the plant migration). */
export const PLANT_KINDS = [
'process_step',
'product',
'department',
'equipment',
'glossary',
'question',
'note',
'person_role'
] as const;
export type PlantKind = (typeof PLANT_KINDS)[number];
export const KIND_LABEL: Record<PlantKind, { one: string; many: string }> = {
process_step: { one: 'process step', many: 'process' },
product: { one: 'product', many: 'products' },
department: { one: 'department', many: 'departments' },
equipment: { one: 'equipment', many: 'equipment' },
glossary: { one: 'glossary word', many: 'glossary' },
question: { one: 'question', many: 'questions' },
note: { one: 'note', many: 'notes' },
person_role: { one: 'role', many: 'roles' }
};
/** Tile gradient per kind (colourful icons on a black-and-white page). */
export const KIND_COLOR: Record<PlantKind, readonly [string, string]> = {
process_step: ['#a78bfa', '#6d28d9'],
product: ['#fcd34d', '#d97706'],
department: ['#7dd3fc', '#0369a1'],
equipment: ['#fdba74', '#c2410c'],
glossary: ['#6ee7b7', '#047857'],
question: ['#f9a8d4', '#be185d'],
note: ['#d4d4d8', '#52525b'],
person_role: ['#93c5fd', '#1d4ed8']
};
/** confirmed: checked at work. assumed: from the guide / general knowledge. open: unknown. */
export const CONFIDENCES = ['confirmed', 'assumed', 'open'] as const;
export type Confidence = (typeof CONFIDENCES)[number];
export const RELATIONS = ['feeds_into', 'made_in', 'uses', 'part_of', 'related'] as const;
export type Relation = (typeof RELATIONS)[number];
/** How a link reads from each end: "A feeds into B" / "B is fed by A". */
export const RELATION_LABEL: Record<Relation, { out: string; in: string }> = {
feeds_into: { out: 'feeds into', in: 'fed by' },
made_in: { out: 'made in', in: 'makes' },
uses: { out: 'uses', in: 'used by' },
part_of: { out: 'part of', in: 'includes' },
related: { out: 'related', in: 'related' }
};
export const slugify = (s: string) =>
s
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
export const entryHref = (e: { kind: string; slug: string }) =>
`/trackers/cargill/${e.kind.replace('_', '-')}/${e.slug}`;
/** URL segment ("process-step") back to the kind ("process_step"). */
export const kindFromSegment = (seg: string): PlantKind | null => {
const k = seg.replace(/-/g, '_');
return (PLANT_KINDS as readonly string[]).includes(k) ? (k as PlantKind) : null;
};
// ---- Process flow layout (pure, unit-tested) -------------------------------------------
export type FlowNode = { id: number; sort: number };
export type FlowEdge = { from: number; to: number; dashed: boolean };
export type Placed = { id: number; layer: number; x: number; y: number };
export const FLOW = { w: 160, h: 50, gapX: 16, gapY: 46, pad: 8 } as const;
/**
* Layered top-to-bottom layout. Layers are the longest path over solid edges (dashed ones,
* like "sort again" loops, don't push nodes down; real cycles are broken by DFS order).
* Within a layer nodes sit near their parents (barycentre), ties by sort order.
*/
export function layoutFlow(nodes: FlowNode[], edges: FlowEdge[]) {
const ids = new Set(nodes.map((n) => n.id));
const solid = edges.filter((e) => !e.dashed && ids.has(e.from) && ids.has(e.to));
const sort = new Map(nodes.map((n) => [n.id, n.sort]));
const out = new Map<number, number[]>();
for (const e of solid) out.set(e.from, [...(out.get(e.from) ?? []), e.to]);
// Drop back edges (cycles) with a DFS in sort order.
const state = new Map<number, 1 | 2>();
const keep: FlowEdge[] = [];
const order = [...nodes].sort((a, b) => a.sort - b.sort || a.id - b.id);
const visit = (id: number) => {
state.set(id, 1);
for (const to of out.get(id) ?? []) {
if (state.get(to) === 1) continue; // back edge
keep.push({ from: id, to, dashed: false });
if (!state.has(to)) visit(to);
}
state.set(id, 2);
};
for (const n of order) if (!state.has(n.id)) visit(n.id);
const layer = new Map(nodes.map((n) => [n.id, 0]));
for (let i = 0; i < nodes.length; i++) {
let changed = false;
for (const e of keep) {
const l = layer.get(e.from)! + 1;
if (l > layer.get(e.to)!) {
layer.set(e.to, l);
changed = true;
}
}
if (!changed) break;
}
const layers: number[][] = [];
for (const n of order) (layers[layer.get(n.id)!] ??= []).push(n.id);
const xIndex = new Map<number, number>();
const parents = new Map<number, number[]>();
for (const e of keep) parents.set(e.to, [...(parents.get(e.to) ?? []), e.from]);
for (const row of layers) {
if (!row) continue;
const bary = (id: number) => {
const ps = (parents.get(id) ?? []).filter((p) => xIndex.has(p));
return ps.length ? ps.reduce((s, p) => s + xIndex.get(p)!, 0) / ps.length : Infinity;
};
row.sort((a, b) => {
const d = bary(a) - bary(b);
return Number.isFinite(d) && d !== 0 ? d : sort.get(a)! - sort.get(b)! || a - b;
});
row.forEach((id, i) => xIndex.set(id, i - (row.length - 1) / 2));
}
const widest = Math.max(1, ...layers.map((r) => r?.length ?? 0));
const width = widest * FLOW.w + (widest - 1) * FLOW.gapX + 2 * FLOW.pad;
const rows = layers.length;
const height = rows * FLOW.h + Math.max(0, rows - 1) * FLOW.gapY + 2 * FLOW.pad;
const placed: Placed[] = nodes.map((n) => {
const l = layer.get(n.id)!;
return {
id: n.id,
layer: l,
x: width / 2 + xIndex.get(n.id)! * (FLOW.w + FLOW.gapX) - FLOW.w / 2,
y: FLOW.pad + l * (FLOW.h + FLOW.gapY)
};
});
return { placed, width, height };
}
// ---- Tabs ------------------------------------------------------------------------------
/** ?tab= value per kind on /trackers/cargill (no tab = the overview). */
export const KIND_TAB: Record<PlantKind, string> = {
process_step: 'process',
product: 'products',
department: 'departments',
equipment: 'equipment',
glossary: 'glossary',
question: 'questions',
note: 'notes',
person_role: 'roles'
};
export const kindFromTab = (tab: string | null): PlantKind | null =>
(Object.entries(KIND_TAB).find(([, t]) => t === tab)?.[0] as PlantKind | undefined) ?? null;
export const tabHref = (kind: PlantKind | null) =>
kind ? `/trackers/cargill?tab=${KIND_TAB[kind]}` : '/trackers/cargill';
/** Case- and accent-insensitive match over title, slug, summary, tags and body. */
export function matches(
e: { title: string; slug: string; summary: string | null; body: string | null; tags: string[] },
q: string
) {
const norm = (s: string) =>
s
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
const words = norm(q).split(/\s+/).filter(Boolean);
if (!words.length) return true;
const hay = norm([e.title, e.slug, e.summary ?? '', e.tags.join(' '), e.body ?? ''].join(' '));
return words.every((w) => hay.includes(w));
}

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import type { Confidence } from '#lib/plant.js';
let { value }: { value: Confidence } = $props();
const TITLE: Record<Confidence, string> = {
confirmed: 'Confirmed: checked or certain',
assumed: 'Assumed: from the guide, not yet checked at work',
open: 'Open: still unknown'
};
</script>
<span class="pill {value}" title={TITLE[value]}>{value}</span>
<style>
.pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.64rem;
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
padding: 0.08rem 0.45rem;
border-radius: 999px;
border: 1px solid;
white-space: nowrap;
}
.pill::before {
content: '';
width: 0.38rem;
height: 0.38rem;
border-radius: 999px;
background: currentColor;
}
.confirmed {
color: #10b981;
border-color: rgb(16 185 129 / 0.4);
}
.assumed {
color: #f59e0b;
border-color: rgb(245 158 11 / 0.4);
}
.open {
color: #ec4899;
border-color: rgb(236 72 153 / 0.4);
}
</style>

View File

@@ -0,0 +1,242 @@
<script lang="ts">
// Owner form to add or edit one entry (markdown body). Posts to ?/save on the current page;
// both /trackers/cargill pages share the actions (#lib/server/plant-actions).
import { enhance, type SubmitFunction } from '$app/forms';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { CONFIDENCES, KIND_LABEL, PLANT_KINDS, type PlantKind } from '#lib/plant.js';
type Entry = {
id: number;
kind: PlantKind;
slug: string;
title: string;
summary: string | null;
body: string | null;
parentId: number | null;
tags: string[];
sortOrder: number;
source: string | null;
confidence: string;
};
let {
entry,
kind = 'note',
entries,
onclose,
openAfterCreate = false
}: {
entry?: Entry;
kind?: PlantKind;
entries: Pick<Entry, 'id' | 'kind' | 'title'>[];
onclose: () => void;
/** After adding, go to the new entry's page (used by the generic "add entry" form). */
openAfterCreate?: boolean;
} = $props();
let busy = $state(false);
let error = $state<string | null>(null);
// svelte-ignore state_referenced_locally
let chosenKind = $state<PlantKind>(entry?.kind ?? kind);
const defaultConfidence = $derived(chosenKind === 'question' ? 'open' : 'assumed');
const submit: SubmitFunction = () => {
busy = true;
error = null;
return async ({ result, update }) => {
busy = false;
if (result.type === 'failure') {
error = String(result.data?.message ?? 'Could not save');
return;
}
if (result.type === 'success') {
const href = result.data?.href as string | undefined;
const here = location.pathname;
if (href && !entry && openAfterCreate)
await goto(resolve(href as '/'), { refreshAll: true });
// Editing on the entry's own page may change its slug or kind: follow it.
else if (href && entry && here.startsWith('/trackers/cargill/') && here !== href)
await goto(resolve(href as '/'), { replace: true, refreshAll: true });
else await update({ reset: false, navigate: false });
onclose();
return;
}
await update({ navigate: false });
};
};
const parents = $derived(entries.filter((e) => e.id !== entry?.id));
</script>
<form class="edit" method="POST" action="?/save" use:enhance={submit}>
{#if entry}<input type="hidden" name="id" value={entry.id} />{/if}
<label
>kind<select name="kind" bind:value={chosenKind}>
{#each PLANT_KINDS as k (k)}<option value={k}>{KIND_LABEL[k].one}</option>{/each}
</select></label
>
<label class="span2"
>title<input
name="title"
value={entry?.title ?? ''}
maxlength="200"
required
placeholder={chosenKind === 'question' ? 'What do I want to know?' : 'Name'}
/></label
>
<label
>confidence<select name="confidence" value={entry?.confidence ?? defaultConfidence}>
{#each CONFIDENCES as c (c)}<option value={c}>{c}</option>{/each}
</select></label
>
<label class="wide"
>summary<input
name="summary"
value={entry?.summary ?? ''}
maxlength="500"
placeholder="one line"
/></label
>
<label class="wide"
>details (markdown)<textarea name="body" rows="7" value={entry?.body ?? ''} maxlength="50000"
></textarea></label
>
<label class="span2"
>tags<input
name="tags"
value={entry?.tags.join(', ') ?? ''}
maxlength="400"
placeholder="comma, separated"
/></label
>
<label class="span2"
>part of<select name="parent_id" value={entry?.parentId ?? ''}>
<option value="">—</option>
{#each parents as p (p.id)}<option value={p.id}>{p.title} · {KIND_LABEL[p.kind].one}</option
>{/each}
</select></label
>
<label
>slug<input
name="slug"
value={entry?.slug ?? ''}
maxlength="80"
placeholder="from title"
pattern="[a-z0-9\-]*"
/></label
>
<label
>order<input
name="sort_order"
type="number"
value={entry?.sortOrder ?? ''}
placeholder="0"
/></label
>
<label class="span2"
>source<input
name="source"
value={entry?.source ?? ''}
maxlength="200"
placeholder="where this came from"
/></label
>
<div class="actions">
<button type="button" class="ghost" onclick={onclose}>cancel</button>
<button type="submit" class="primary" disabled={busy}>{entry ? 'save' : 'add'}</button>
</div>
{#if error}<p class="error" role="alert">{error}</p>{/if}
</form>
<style>
.edit {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.5rem;
align-items: end;
padding: 0.75rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-bg);
}
label {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
min-width: 0;
}
.span2 {
grid-column: span 2;
}
.wide {
grid-column: 1 / -1;
}
input,
select,
textarea {
font: inherit;
font-size: 0.85rem;
text-transform: none;
letter-spacing: normal;
color: var(--site-text);
background: var(--site-surface);
border: 1px solid var(--site-border);
border-radius: 0.5rem;
padding: 0.35rem 0.5rem;
min-width: 0;
width: 100%;
box-sizing: border-box;
}
input,
select {
height: 2.1rem;
}
textarea {
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8rem;
line-height: 1.45;
}
.actions {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.error {
grid-column: 1 / -1;
color: #f87171;
font-size: 0.8rem;
}
button {
cursor: pointer;
font: inherit;
font-size: 0.85rem;
border-radius: 0.5rem;
}
.primary {
padding: 0.4rem 0.9rem;
background: var(--site-text);
color: var(--site-bg);
font-weight: 600;
}
.primary:disabled {
opacity: 0.5;
}
.ghost {
padding: 0.4rem 0.75rem;
border: 1px solid var(--site-border);
color: var(--site-muted);
}
.ghost:hover {
color: var(--site-text);
}
@media (max-width: 640px) {
.edit {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View File

@@ -0,0 +1,256 @@
<script lang="ts">
// The plant's process flow, drawn from feeds_into links: every entry on such a link is a
// node (process steps as cards, raw materials / outputs as dashed pills), laid out top to
// bottom by layoutFlow (#lib/plant). Dashed links are optional paths or loops back.
import { FLOW, KIND_COLOR, entryHref, layoutFlow, type PlantKind } from '#lib/plant.js';
type Node = {
id: number;
kind: PlantKind;
slug: string;
title: string;
summary: string | null;
sortOrder: number;
};
type Link = {
id: number;
fromId: number;
toId: number;
relation: string;
label: string | null;
dashed: boolean;
confidence?: string;
};
let { entries, links }: { entries: Node[]; links: Link[] } = $props();
const flow = $derived.by(() => {
// Only the process: feeds_into links that touch a process step (not product -> department).
const kindOf = new Map(entries.map((e) => [e.id, e.kind]));
const feeds = links.filter(
(l) =>
l.relation === 'feeds_into' &&
(kindOf.get(l.fromId) === 'process_step' || kindOf.get(l.toId) === 'process_step')
);
const ids = new Set(feeds.flatMap((l) => [l.fromId, l.toId]));
// Order: process steps by their sort order; inputs first, outputs last.
const nodes = entries
.filter((e) => ids.has(e.id))
.map((e) => ({
...e,
sort:
(e.kind === 'process_step' ? 1000 : feeds.some((l) => l.toId === e.id) ? 5000 : 0) +
e.sortOrder
}));
const { placed, width, height } = layoutFlow(
nodes.map((n) => ({ id: n.id, sort: n.sort })),
feeds.map((l) => ({ from: l.fromId, to: l.toId, dashed: l.dashed }))
);
const pos = new Map(placed.map((p) => [p.id, p]));
const byId = new Map(nodes.map((n) => [n.id, n]));
// Spread several edges over a node's bottom (out) and top (in) edge.
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
const outs = new Map<number, Link[]>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
const ins = new Map<number, Link[]>();
for (const l of feeds) {
outs.set(l.fromId, [...(outs.get(l.fromId) ?? []), l]);
ins.set(l.toId, [...(ins.get(l.toId) ?? []), l]);
}
const port = (list: Link[], l: Link, x: (o: Link) => number) => {
const sorted = [...list].sort((a, b) => x(a) - x(b));
const i = sorted.indexOf(l);
return (i - (sorted.length - 1) / 2) * Math.min(22, (FLOW.w - 30) / sorted.length);
};
const cx = (id: number) => pos.get(id)!.x + FLOW.w / 2;
const placedNodes = placed.map((p) => ({ ...p, node: byId.get(p.id)! }));
const mid = width / 2;
const M = 12; // clearance between a lane and a node
let minX = 0;
let maxX = width;
const edges = feeds.map((l) => {
const a = pos.get(l.fromId)!;
const b = pos.get(l.toId)!;
let d: string;
let lx: number;
let ly: number;
if (b.layer > a.layer) {
const x1 = cx(l.fromId) + port(outs.get(l.fromId)!, l, (o) => cx(o.toId));
const y1 = a.y + FLOW.h;
const x2 = cx(l.toId) + port(ins.get(l.toId)!, l, (o) => cx(o.fromId));
const y2 = b.y - 4;
// Nodes on the layers in between that a straight-ish line would cross.
const between = placed.filter((p) => p.layer > a.layer && p.layer < b.layer);
const hits = (x: number) => between.some((p) => x > p.x - M && x < p.x + FLOW.w + M);
const lo = Math.min(x1, x2);
const hi = Math.max(x1, x2);
const crossed = between.filter((p) => p.x + FLOW.w + M > lo && p.x - M < hi);
if (!crossed.length) {
const my = (y2 - y1) * 0.5;
d = `M${x1} ${y1} C${x1} ${y1 + my} ${x2} ${y2 - my} ${x2} ${y2}`;
lx = (x1 + x2) / 2;
ly = (y1 + y2) / 2;
} else {
// Long edge: run down a free vertical lane beside the nodes it would cross.
let right = Math.max(...crossed.map((p) => p.x + FLOW.w)) + M;
while (hits(right)) right += FLOW.gapX;
let left = Math.min(...crossed.map((p) => p.x)) - M;
while (hits(left)) left -= FLOW.gapX;
const cost = (x: number) => Math.abs(x - x1) + Math.abs(x - x2);
const lane = cost(left) < cost(right) ? left : right;
const g = FLOW.gapY / 2;
const ya = y1 + g;
const yb = y2 - g;
d =
`M${x1} ${y1} C${x1} ${y1 + g * 0.7} ${lane} ${ya - g * 0.7} ${lane} ${ya}` +
` L${lane} ${yb} C${lane} ${yb + g * 0.7} ${x2} ${y2 - g * 0.7} ${x2} ${y2}`;
lx = lane;
ly = (ya + yb) / 2;
minX = Math.min(minX, lane - 40);
maxX = Math.max(maxX, lane + 40);
}
} else {
// Loop back (or sideways): out of the outer side and back in on the same side.
const leftSide = a.x + FLOW.w / 2 + (b.x + FLOW.w / 2) < 2 * mid;
const x1 = leftSide ? a.x : a.x + FLOW.w;
const x2 = leftSide ? b.x - 4 : b.x + FLOW.w + 4;
const y1 = a.y + FLOW.h / 2;
const y2 = b.y + FLOW.h / 2;
const bulge = 30 + Math.abs(y1 - y2) * 0.1;
const out = leftSide ? Math.min(x1, x2) - bulge : Math.max(x1, x2) + bulge;
d = `M${x1} ${y1} C${out} ${y1} ${out} ${y2} ${x2} ${y2}`;
lx = leftSide ? Math.min(x1, x2) - bulge * 0.75 : Math.max(x1, x2) + bulge * 0.75;
ly = (y1 + y2) / 2;
minX = Math.min(minX, out - 30);
maxX = Math.max(maxX, out + 30);
}
return { ...l, d, lx, ly };
});
return { nodes: placedNodes, edges, minX, width: maxX - minX, height };
});
const clip = (s: string | null, n: number) =>
!s ? '' : s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s;
</script>
{#if flow.nodes.length}
<svg
class="flow"
viewBox="{flow.minX} 0 {flow.width} {flow.height}"
style:max-width="{flow.width}px"
role="img"
aria-label="Process flow of the plant"
>
<defs>
<marker
id="plant-arrow"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="7"
markerHeight="7"
orient="auto-start-reverse"
>
<path d="M1 1L9 5L1 9z" class="arrowhead" />
</marker>
</defs>
{#each flow.edges as e (e.id)}
<path
class={['edge', { dashed: e.dashed || (e.confidence ?? 'confirmed') !== 'confirmed' }]}
d={e.d}
marker-end="url(#plant-arrow)"
/>
{/each}
{#each flow.edges as e (e.id)}
{#if e.label}
{@const w = e.label.length * 5.4 + 10}
<g class="elabel" transform="translate({e.lx} {e.ly})">
<rect x={-w / 2} y="-8" width={w} height="16" rx="8" />
<text text-anchor="middle" dy="3.5">{e.label}</text>
</g>
{/if}
{/each}
{#each flow.nodes as p (p.id)}
{@const n = p.node}
{@const step = n.kind === 'process_step'}
<a href={entryHref(n)} class="node-link">
<g class={['node', { step, terminal: !step }]} transform="translate({p.x} {p.y})">
<title>{n.title}{n.summary ? ` — ${n.summary}` : ''}</title>
<rect width={FLOW.w} height={FLOW.h} rx={step ? 10 : 25} />
<circle cx="14" cy={FLOW.h / 2} r="4.5" fill={KIND_COLOR[n.kind][1]} />
<text class="t" x="25" y={n.summary ? 21 : 29}>{clip(n.title, 20)}</text>
{#if n.summary}<text class="s" x="25" y="36">{clip(n.summary, 26)}</text>{/if}
</g>
</a>
{/each}
</svg>
{:else}
<p class="empty">No flow yet: link process steps with “feeds into”.</p>
{/if}
<style>
.flow {
display: block;
width: 100%;
height: auto;
margin: 0 auto;
overflow: visible;
}
.edge {
fill: none;
stroke: var(--site-muted);
stroke-opacity: 0.7;
stroke-width: 1.4;
}
.edge.dashed {
stroke-dasharray: 4 4;
stroke-opacity: 0.55;
}
.arrowhead {
fill: var(--site-muted);
}
.elabel rect {
fill: var(--site-bg);
stroke: var(--site-border);
}
.elabel text {
font-size: 9.5px;
fill: var(--site-muted);
}
.node rect {
fill: var(--site-surface);
stroke: var(--site-border);
stroke-width: 1.2;
transition:
stroke 0.15s,
fill 0.15s;
}
.node.terminal rect {
fill: var(--site-bg);
stroke-dasharray: 3 3;
}
.node-link:hover rect,
.node-link:focus-visible rect {
stroke: var(--site-text);
}
.node-link:focus-visible {
outline: none;
}
.t {
font-size: 12.5px;
font-weight: 600;
fill: var(--site-text);
}
.s {
font-size: 10px;
fill: var(--site-muted);
}
@media (max-width: 640px) {
/* Keep the labels readable on phones: the card scrolls sideways instead. */
.flow {
min-width: 30rem;
}
}
.empty {
color: var(--site-muted);
font-size: 0.85rem;
}
</style>

View File

@@ -0,0 +1,51 @@
<script lang="ts">
// A small gradient tile with the kind's icon (colourful icons on a black-and-white page).
import { KIND_COLOR, KIND_LABEL, type PlantKind } from '#lib/plant.js';
import Workflow from 'lucide-svelte/icons/workflow';
import Package from 'lucide-svelte/icons/package';
import Building2 from 'lucide-svelte/icons/building-2';
import Wrench from 'lucide-svelte/icons/wrench';
import BookA from 'lucide-svelte/icons/book-a';
import CircleQuestionMark from 'lucide-svelte/icons/circle-question-mark';
import StickyNote from 'lucide-svelte/icons/sticky-note';
import UserRound from 'lucide-svelte/icons/user-round';
let { kind, size = 22 }: { kind: PlantKind; size?: number } = $props();
const ICONS = {
process_step: Workflow,
product: Package,
department: Building2,
equipment: Wrench,
glossary: BookA,
question: CircleQuestionMark,
note: StickyNote,
person_role: UserRound
} as const;
const Icon = $derived(ICONS[kind] ?? StickyNote);
const colors = $derived(KIND_COLOR[kind] ?? KIND_COLOR.note);
</script>
<span
class="kind-icon"
style:--from={colors[0]}
style:--to={colors[1]}
style:--size="{size}px"
title={KIND_LABEL[kind]?.one ?? kind}
aria-hidden="true"><Icon size={Math.round(size * 0.58)} strokeWidth={2.25} /></span
>
<style>
.kind-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: var(--size);
height: var(--size);
border-radius: calc(var(--size) * 0.3);
color: #fff;
background: linear-gradient(135deg, var(--from), var(--to));
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.12);
}
</style>

View File

@@ -387,6 +387,13 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
external: false, external: false,
private: true private: true
}, },
{
name: 'cargill',
subtitle: 'what the Cargill Bioindustrial plant in Gouda does: process, products and words',
href: '/trackers/cargill',
external: false,
private: true
},
{ {
name: 'math', name: 'math',
subtitle: "working through Evan Chen's Napkin: chapters, exercises and notes", subtitle: "working through Evan Chen's Napkin: chapters, exercises and notes",

View File

@@ -34,3 +34,4 @@ export * from './worldtodo';
export * from './osu-goals'; export * from './osu-goals';
export * from './notes'; export * from './notes';
export * from './math'; export * from './math';
export * from './plant';

View File

@@ -0,0 +1,118 @@
import { sql } from 'drizzle-orm';
import {
boolean,
check,
index,
integer,
text,
uniqueIndex,
type AnyPgColumn
} from 'drizzle-orm/pg-core';
import { plantSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
// Cargill plant tracker (/trackers/cargill, owner-only): what the Cargill Bioindustrial plant
// does. One generic `entries` table (departments, products, process steps, equipment, glossary,
// questions, notes, roles) plus typed `links` between them. Bots apply JSON with
// `select plant.apply_entries('{...}'::jsonb)` (shape documented in the migration and in
// docs/plant-tracker.md). Kinds, confidences and relations are kept in sync with #lib/plant.
export const plantEntries = plantSchema.table(
'entries',
{
id: id(),
/** process_step | product | department | equipment | glossary | question | note | person_role */
kind: text('kind').notNull(),
/** Unique per kind; used in URLs and by apply_entries. */
slug: text('slug').notNull(),
title: text('title').notNull(),
summary: text('summary'),
/** Markdown. */
body: text('body'),
parentId: integer('parent_id').references((): AnyPgColumn => plantEntries.id, {
onDelete: 'set null',
onUpdate: 'cascade'
}),
tags: text('tags')
.array()
.notNull()
.default(sql`'{}'::text[]`),
sortOrder: integer('sort_order').notNull().default(0),
/** Where it came from, e.g. "page 2026-10-26", "Walter White", "Stan". */
source: text('source'),
/** confirmed (checked at work) | assumed (from the guide) | open (unknown). */
confidence: text('confidence').notNull().default('assumed'),
/** Questions: when it was answered (null = still open). */
answeredAt: timestamptz('answered_at'),
...timestamps
},
(t) => [
uniqueIndex('entries_kind_slug_key').on(t.kind, t.slug),
index('entries_parent_idx').on(t.parentId),
check(
'entries_kind_check',
sql`${t.kind} IN ('process_step','product','department','equipment','glossary','question','note','person_role')`
),
check('entries_confidence_check', sql`${t.confidence} IN ('confirmed','assumed','open')`),
check('entries_slug_check', sql`${t.slug} ~ '^[a-z0-9]+(-[a-z0-9]+)*$'`)
]
);
export const plantLinks = plantSchema.table(
'links',
{
id: id(),
fromId: integer('from_id')
.notNull()
.references(() => plantEntries.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
toId: integer('to_id')
.notNull()
.references(() => plantEntries.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
/** feeds_into | made_in | uses | part_of | related */
relation: text('relation').notNull(),
/** Short edge label, e.g. "fatty acids". */
label: text('label'),
/** Drawn dashed in the flow (optional path or a loop back); doesn't push layers down. */
dashed: boolean('dashed').notNull().default(false),
/** confirmed | assumed | open; not-confirmed links are drawn dashed with a "?". */
confidence: text('confidence').notNull().default('confirmed'),
createdAt: timestamptz('created_at').notNull().defaultNow()
},
(t) => [
check('links_confidence_check', sql`${t.confidence} IN ('confirmed','assumed','open')`),
uniqueIndex('links_from_to_relation_key').on(t.fromId, t.toId, t.relation),
index('links_to_idx').on(t.toId),
check(
'links_relation_check',
sql`${t.relation} IN ('feeds_into','made_in','uses','part_of','related')`
),
check('links_not_self_check', sql`${t.fromId} <> ${t.toId}`)
]
);
/** Parents and links whose target isn't in the tracker yet. apply_entries retries them on every
* run, so a later batch that adds the target resolves them; the detail page lists them as pending. */
export const plantPending = plantSchema.table(
'pending',
{
id: id(),
fromId: integer('from_id')
.notNull()
.references(() => plantEntries.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
/** "parent" or a link relation. */
field: text('field').notNull(),
/** The reference as given ("slug" or "kind:slug"). */
toRef: text('to_ref').notNull(),
label: text('label'),
dashed: boolean('dashed').notNull().default(false),
confidence: text('confidence').notNull().default('confirmed'),
createdAt: timestamptz('created_at').notNull().defaultNow()
},
(t) => [
uniqueIndex('pending_from_field_ref_key').on(t.fromId, t.field, t.toRef),
check(
'pending_field_check',
sql`${t.field} IN ('parent','feeds_into','made_in','uses','part_of','related')`
)
]
);

View File

@@ -30,3 +30,4 @@ export const moviesSchema = pgSchema('movies');
export const worldtodoSchema = pgSchema('worldtodo'); export const worldtodoSchema = pgSchema('worldtodo');
export const notesSchema = pgSchema('notes'); export const notesSchema = pgSchema('notes');
export const mathSchema = pgSchema('math'); export const mathSchema = pgSchema('math');
export const plantSchema = pgSchema('plant');

View File

@@ -43,6 +43,14 @@ describe('owner gate', () => {
expect(isOwnerOnlyPath('/trackers/math/__data.json')).toBe(MATH_PRIVATE); expect(isOwnerOnlyPath('/trackers/math/__data.json')).toBe(MATH_PRIVATE);
expect(isOwnerWritePath('/trackers/math')).toBe(true); expect(isOwnerWritePath('/trackers/math')).toBe(true);
}); });
it('gates the whole cargill plant tracker, reads and writes', () => {
expect(isOwnerOnlyPath('/trackers/cargill')).toBe(true);
expect(isOwnerOnlyPath('/trackers/cargill/__data.json')).toBe(true);
expect(isOwnerOnlyPath('/trackers/cargill/process-step/cleaning')).toBe(true);
expect(isOwnerOnlyPath('/trackers/cargillx')).toBe(false);
expect(isOwnerWritePath('/trackers/cargill')).toBe(true);
expect(isOwnerWritePath('/trackers/cargill/glossary/ester')).toBe(true);
});
it('only lets the owner write in owner areas', () => { it('only lets the owner write in owner areas', () => {
expect(isOwnerWritePath('/trackers/books/12')).toBe(true); expect(isOwnerWritePath('/trackers/books/12')).toBe(true);
expect(isOwnerWritePath('/trackers/books')).toBe(true); expect(isOwnerWritePath('/trackers/books')).toBe(true);

View File

@@ -40,6 +40,8 @@ export const ownerOnlyPrefixes = [
// Worldwide todo list: the list is public, the review queue and its API are mine only. // Worldwide todo list: the list is public, the review queue and its API are mine only.
'/projects/worldwide-todo/queue', '/projects/worldwide-todo/queue',
'/projects/worldwide-todo/api/queue', '/projects/worldwide-todo/api/queue',
// Cargill plant tracker: what the factory I work at does. Mine only.
'/trackers/cargill',
// Math tracker (the Napkin): owner-only while MATH_PRIVATE (#lib/math) is on. // Math tracker (the Napkin): owner-only while MATH_PRIVATE (#lib/math) is on.
...(MATH_PRIVATE ? (['/trackers/math'] as const) : []) ...(MATH_PRIVATE ? (['/trackers/math'] as const) : [])
] as const; ] as const;
@@ -71,7 +73,8 @@ export const ownerWritePrefixes = [
'/trackers/notes', '/trackers/notes',
'/trackers/ai', '/trackers/ai',
'/trackers/movies', '/trackers/movies',
'/trackers/math' '/trackers/math',
'/trackers/cargill'
] as const; ] as const;
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, ''); const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');

View File

@@ -0,0 +1,27 @@
// Form actions shared by the /trackers/cargill overview and entry pages. Actions don't run the
// load, so each one checks the owner again (hooks.server.ts also 404s the whole path).
import { requireOwner } from '#lib/server/owner.js';
import { addLink, answerQuestion, deleteEntry, removeLink, saveEntry } from '#lib/server/plant.js';
import { fail, redirect, type RequestEvent } from '@sveltejs/kit';
type Handler = (
form: FormData
) => Promise<{ ok: true; href?: string } | { ok: false; message: string }>;
const action = (fn: Handler) => async (event: RequestEvent) => {
requireOwner(event);
const form = await event.request.formData();
const r = await fn(form);
if (!r.ok) return fail(400, { message: r.message });
// Deleting from an entry's own page goes back to the overview.
if (form.get('redirect') === '1' && r.href) redirect(303, r.href);
return { ok: true as const, href: r.href };
};
export const plantActions = {
save: action(saveEntry),
delete: action(deleteEntry),
answer: action(answerQuestion),
link: action(addLink),
unlink: action(removeLink)
};

232
src/lib/server/plant.ts Normal file
View File

@@ -0,0 +1,232 @@
// Cargill plant tracker (/trackers/cargill, owner-only): loads entries and links, renders
// markdown bodies, and applies owner edits. Every write goes through plant.apply_entries (the
// same JSON format bots use, see the plant migration) or a plain delete, so the site and bots
// share one code path.
import { client } from '#lib/server/db/index.js';
import {
CONFIDENCES,
PLANT_KINDS,
RELATIONS,
slugify,
type Confidence,
type PlantKind,
type Relation
} from '#lib/plant.js';
import { Marked } from 'marked';
import * as z from 'zod';
// Markdown bodies. Raw HTML is shown as text, never rendered; only http(s)/mailto/relative links.
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
const marked = new Marked({
gfm: true,
breaks: true,
renderer: {
html: ({ text }) => escapeHtml(text),
link({ href, title, tokens }) {
const label = this.parser.parseInline(tokens);
if (!/^(https?:|mailto:|\/|#)/i.test(href)) return label;
const t = title ? ` title="${escapeHtml(title)}"` : '';
const ext = /^(https?:|mailto:)/i.test(href)
? ' rel="noopener noreferrer" target="_blank"'
: '';
return `<a href="${escapeHtml(href)}"${t}${ext}>${label}</a>`;
},
image: ({ text }) => escapeHtml(text)
}
});
export const renderBody = (md: string) => marked.parse(md, { async: false });
export type PlantEntry = {
id: number;
kind: PlantKind;
slug: string;
title: string;
summary: string | null;
body: string | null;
parentId: number | null;
tags: string[];
sortOrder: number;
source: string | null;
confidence: Confidence;
answeredAt: string | null;
createdAt: string;
updatedAt: string;
};
export type PlantLink = {
id: number;
fromId: number;
toId: number;
relation: Relation;
label: string | null;
dashed: boolean;
/** Not-confirmed links show dashed with a "?". */
confidence: Confidence;
};
const iso = (d: Date | string | null) => (d ? new Date(d).toISOString() : null);
export async function loadPlant() {
const [entries, links] = await Promise.all([
client`SELECT id, kind, slug, title, summary, body, parent_id, tags, sort_order, source,
confidence, answered_at, created_at, updated_at
FROM plant.entries ORDER BY kind, sort_order, lower(title)`,
client`SELECT id, from_id, to_id, relation, label, dashed, confidence
FROM plant.links ORDER BY id`
]);
return {
entries: entries.map((e): PlantEntry => ({
id: e.id,
kind: e.kind,
slug: e.slug,
title: e.title,
summary: e.summary,
body: e.body,
parentId: e.parent_id,
tags: e.tags ?? [],
sortOrder: e.sort_order,
source: e.source,
confidence: e.confidence,
answeredAt: iso(e.answered_at),
createdAt: iso(e.created_at)!,
updatedAt: iso(e.updated_at)!
})),
links: links.map((l): PlantLink => ({
id: l.id,
fromId: l.from_id,
toId: l.to_id,
relation: l.relation,
label: l.label,
dashed: l.dashed,
confidence: l.confidence
}))
};
}
type Result = { ok: true; href?: string } | { ok: false; message: string };
const text = (max: number) => z.string().trim().max(max);
const entryInput = z.object({
id: z.coerce.number().int().positive().optional(),
kind: z.enum(PLANT_KINDS),
title: text(200).min(1, 'A title is required'),
slug: text(80).optional(),
summary: text(500).optional(),
body: z.string().max(50_000).optional(),
tags: text(400).optional(),
parent_id: z.union([z.literal(''), z.coerce.number().int().positive()]).optional(),
sort_order: z.union([z.literal(''), z.coerce.number().int().min(-100000).max(100000)]).optional(),
source: text(200).optional(),
confidence: z.enum(CONFIDENCES)
});
const formObject = (form: FormData) => {
const o: Record<string, string> = {};
for (const [k, v] of form) if (typeof v === 'string') o[k] = v;
return o;
};
const dbMessage = (e: unknown) => {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes('entries_kind_slug_key')) return 'That slug is already used for this kind';
if (msg.startsWith('plant')) return msg.replace(/^plant(\.apply_entries)?: /, '');
throw e;
};
/** Create (no id) or update (id) one entry from the owner form. */
export async function saveEntry(form: FormData): Promise<Result> {
const parsed = entryInput.safeParse(formObject(form));
if (!parsed.success) return { ok: false, message: parsed.error.issues[0]?.message ?? 'Invalid' };
const p = parsed.data;
const slug = slugify(p.slug || p.title);
if (!slug) return { ok: false, message: 'Use some letters or digits in the title or slug' };
const tags = (p.tags ?? '')
.split(',')
.map((t) => t.trim())
.filter(Boolean)
.slice(0, 20);
const parentId = typeof p.parent_id === 'number' ? p.parent_id : null;
if (parentId && parentId === p.id)
return { ok: false, message: 'An entry cannot be its own parent' };
try {
if (p.id) {
// Updates go straight to the row (kind and slug may change, which apply_entries keys on).
const rows = await client`UPDATE plant.entries SET
kind = ${p.kind}, slug = ${slug}, title = ${p.title},
summary = ${p.summary || null}, body = ${p.body?.trim() || null},
tags = ${tags}, parent_id = ${parentId},
sort_order = ${typeof p.sort_order === 'number' ? p.sort_order : 0},
source = ${p.source || null}, confidence = ${p.confidence}
WHERE id = ${p.id} RETURNING kind, slug`;
if (!rows.length) return { ok: false, message: 'That entry no longer exists' };
return {
ok: true,
href: `/trackers/cargill/${rows[0].kind.replace('_', '-')}/${rows[0].slug}`
};
}
const [exists] =
await client`SELECT 1 FROM plant.entries WHERE kind = ${p.kind} AND slug = ${slug}`;
if (exists) return { ok: false, message: 'That slug is already used for this kind' };
const json: Record<string, unknown> = {
kind: p.kind,
slug,
title: p.title,
confidence: p.confidence,
source: p.source || 'Stan',
tags
};
if (p.summary) json.summary = p.summary;
if (p.body?.trim()) json.body = p.body;
if (typeof p.sort_order === 'number') json.sort_order = p.sort_order;
await client`SELECT plant.apply_entries(${JSON.stringify({ entries: [json] })}::jsonb)`;
if (parentId)
await client`UPDATE plant.entries SET parent_id = ${parentId} WHERE kind = ${p.kind} AND slug = ${slug}`;
return { ok: true, href: `/trackers/cargill/${p.kind.replace('_', '-')}/${slug}` };
} catch (e) {
return { ok: false, message: dbMessage(e) };
}
}
const idOnly = z.object({ id: z.coerce.number().int().positive() });
export async function deleteEntry(form: FormData): Promise<Result> {
const p = idOnly.safeParse({ id: form.get('id') });
if (!p.success) return { ok: false, message: 'Invalid entry' };
await client`DELETE FROM plant.entries WHERE id = ${p.data.id}`;
return { ok: true, href: '/trackers/cargill' };
}
/** Questions: toggle answered (answered=1 marks it, 0 reopens). */
export async function answerQuestion(form: FormData): Promise<Result> {
const p = idOnly.safeParse({ id: form.get('id') });
if (!p.success) return { ok: false, message: 'Invalid question' };
const on = form.get('answered') === '1';
await client`UPDATE plant.entries
SET answered_at = CASE WHEN ${on}::boolean THEN coalesce(answered_at, now()) END
WHERE id = ${p.data.id} AND kind = 'question'`;
return { ok: true };
}
const linkInput = z.object({
from_id: z.coerce.number().int().positive(),
to_id: z.coerce.number().int().positive(),
relation: z.enum(RELATIONS),
label: text(80).optional(),
dashed: z.literal('1').optional()
});
export async function addLink(form: FormData): Promise<Result> {
const parsed = linkInput.safeParse(formObject(form));
if (!parsed.success) return { ok: false, message: 'Pick an entry and a relation' };
const l = parsed.data;
if (l.from_id === l.to_id) return { ok: false, message: 'An entry cannot link to itself' };
await client`INSERT INTO plant.links (from_id, to_id, relation, label, dashed)
VALUES (${l.from_id}, ${l.to_id}, ${l.relation}, ${l.label || null}, ${l.dashed === '1'})
ON CONFLICT (from_id, to_id, relation) DO UPDATE SET label = excluded.label, dashed = excluded.dashed`;
return { ok: true };
}
export async function removeLink(form: FormData): Promise<Result> {
const p = idOnly.safeParse({ id: form.get('id') });
if (!p.success) return { ok: false, message: 'Invalid link' };
await client`DELETE FROM plant.links WHERE id = ${p.data.id}`;
return { ok: true };
}

View File

@@ -96,6 +96,13 @@ export const trackerIcons: Record<string, TrackerIcon> = {
'M20 3v4M22 5h-4M4 17v2M5 18H3' 'M20 3v4M22 5h-4M4 17v2M5 18H3'
] ]
}, },
cargill: {
from: '#a3e635',
to: '#4d7c0f',
ink: WHITE,
// factory: saw-tooth roof, chimney and windows
paths: ['M3 20V10l5 3V10l5 3V10l5 3V4h3v16z', 'M7 17h1M11 17h1M15 17h1']
},
math: { math: {
from: '#60a5fa', from: '#60a5fa',
to: '#1e40af', to: '#1e40af',

View File

@@ -0,0 +1,11 @@
import { requireOwner } from '#lib/server/owner.js';
import { loadPlant } from '#lib/server/plant.js';
import type { LayoutServerLoad } from './$types';
// /trackers/cargill: owner-only notes on what the Cargill Bioindustrial plant in Gouda does.
// Everyone else gets a 404 (here and in hooks.server.ts via ownerOnlyPrefixes); hooks.server.ts
// also sends cache-control: private, no-store on owner-only paths.
export const load: LayoutServerLoad = async (event) => {
requireOwner(event);
return { ...(await loadPlant()), now: Date.now() };
};

View File

@@ -0,0 +1,394 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
import KindIcon from '#lib/plant/KindIcon.svelte';
import {
KIND_LABEL,
KIND_TAB,
PLANT_KINDS,
kindFromSegment,
kindFromTab,
tabHref,
type PlantKind
} from '#lib/plant.js';
import Factory from 'lucide-svelte/icons/factory';
import Search from 'lucide-svelte/icons/search';
import X from 'lucide-svelte/icons/x';
let { data, children } = $props();
const counts = $derived.by(() => {
const c = Object.fromEntries(PLANT_KINDS.map((k) => [k, 0])) as Record<PlantKind, number>;
for (const e of data.entries) c[e.kind]++;
return c;
});
// Roles only show up once there is one (they're the least used kind).
const tabs = $derived(PLANT_KINDS.filter((k) => k !== 'person_role' || counts[k] > 0));
const active = $derived<PlantKind | 'overview'>(
page.params.kind
? (kindFromSegment(page.params.kind) ?? 'overview')
: (kindFromTab(page.url.searchParams.get('tab')) ?? 'overview')
);
const openQuestions = $derived(
data.entries.filter((e) => e.kind === 'question' && !e.answeredAt).length
);
const q = $derived(page.url.searchParams.get('q') ?? '');
let typed = $state<string | null>(null);
const search = (value: string) => {
typed = value;
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, used once
const params = new URLSearchParams();
if (value.trim()) params.set('q', value);
const qs = params.size ? `?${params}` : '';
void goto(resolve(`/trackers/cargill${qs}` as '/'), {
reset: false,
replace: page.url.pathname === '/trackers/cargill'
});
};
$effect(() => {
void page.url;
typed = null;
});
</script>
<div class="plant">
<header class="plant-head">
<a href={resolve('/trackers/cargill')} class="title-link">
<TrackerIcon name="cargill" class="size-10" />
</a>
<div class="title">
<h1>cargill<PrivateLock size={14} /></h1>
<p>What the Cargill Bioindustrial plant in Gouda does: the process, products and words.</p>
</div>
<label class="search">
<Search size={15} />
<input
type="search"
placeholder="search everything"
aria-label="Search the plant notes"
value={typed ?? q}
oninput={(e) => search(e.currentTarget.value)}
/>
{#if typed ?? q}<button
type="button"
class="clear"
aria-label="clear search"
onclick={() => search('')}><X size={14} /></button
>{/if}
</label>
</header>
<nav class="plant-tabs" aria-label="Plant tracker sections">
<a
href={resolve('/trackers/cargill')}
class={{ active: active === 'overview' && !q }}
aria-current={active === 'overview' && !q ? 'page' : undefined}
><span class="tab-icon overview"><Factory size={13} /></span>overview</a
>
{#each tabs as k (k)}
<a
href={resolve(tabHref(k) as '/')}
class={{ active: active === k && !q }}
aria-current={active === k && !q ? 'page' : undefined}
data-tab={KIND_TAB[k]}
><KindIcon kind={k} size={22} />{KIND_LABEL[k].many}<span class="n"
>{k === 'question' ? `${openQuestions}/${counts[k]}` : counts[k]}</span
></a
>
{/each}
</nav>
{@render children()}
</div>
<style>
.plant {
max-width: 72rem;
margin: 0 auto;
padding: 2rem 1rem 3rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.plant-head {
display: flex;
align-items: center;
gap: 0.85rem;
flex-wrap: wrap;
}
.title-link {
display: inline-flex;
}
.title {
flex: 1 1 18rem;
min-width: 0;
}
h1 {
font-size: 2rem;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.1;
}
.title p {
color: var(--site-muted);
font-size: 0.9rem;
}
.search {
display: flex;
align-items: center;
gap: 0.4rem;
flex: 0 1 17rem;
padding: 0 0.6rem;
height: 2.2rem;
border: 1px solid var(--site-border);
border-radius: 999px;
color: var(--site-muted);
background: var(--site-surface);
}
.search:focus-within {
border-color: var(--site-muted);
}
.search input {
flex: 1;
min-width: 0;
background: transparent;
border: 0;
outline: none;
font: inherit;
font-size: 0.85rem;
color: var(--site-text);
}
.search input::-webkit-search-cancel-button {
display: none;
}
.clear {
display: inline-flex;
cursor: pointer;
color: var(--site-muted);
}
.plant-tabs {
display: flex;
gap: 0.35rem;
overflow-x: auto;
scrollbar-width: none;
border-bottom: 1px solid var(--site-border);
padding-bottom: 0.6rem;
}
.plant-tabs a {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.6rem 0.25rem 0.25rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.83rem;
color: var(--site-muted);
white-space: nowrap;
text-decoration: none;
transition:
color 0.15s,
border-color 0.15s,
background 0.15s;
}
.plant-tabs a :global(.kind-icon) {
border-radius: 999px;
}
.plant-tabs a:hover {
color: var(--site-text);
border-color: var(--site-muted);
}
.plant-tabs a.active {
background: var(--site-text);
border-color: var(--site-text);
color: var(--site-bg);
}
.n {
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.tab-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 999px;
color: #fff;
}
.tab-icon.overview {
background: linear-gradient(135deg, #a3e635, #4d7c0f);
}
/* Shared building blocks for the overview and entry pages. */
.plant :global(.card) {
padding: 0.9rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
min-width: 0;
}
.plant :global(.card-title) {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
margin-bottom: 0.6rem;
}
.plant :global(.bar) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
flex-wrap: wrap;
}
.plant :global(.meta),
.plant :global(.empty) {
color: var(--site-muted);
font-size: 0.85rem;
}
.plant :global(.empty) {
padding: 1.5rem 0;
}
.plant :global(button) {
cursor: pointer;
font: inherit;
}
.plant :global(.primary) {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.8rem;
border-radius: 0.5rem;
background: var(--site-text);
color: var(--site-bg);
font-size: 0.83rem;
font-weight: 600;
transition: opacity 0.15s;
}
.plant :global(.primary:hover) {
opacity: 0.85;
}
.plant :global(.ghost) {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.7rem;
border-radius: 0.5rem;
border: 1px solid var(--site-border);
color: var(--site-muted);
font-size: 0.83rem;
}
.plant :global(.ghost:hover) {
color: var(--site-text);
border-color: var(--site-muted);
}
.plant :global(.icon) {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.3rem;
border-radius: 0.4rem;
color: var(--site-muted);
transition:
color 0.15s,
background 0.15s;
}
.plant :global(.icon:hover) {
color: var(--site-text);
background: color-mix(in srgb, var(--site-text) 8%, transparent);
}
.plant :global(.icon.danger:hover) {
color: #f87171;
background: rgb(248 113 113 / 0.1);
}
.plant :global(.tag) {
font-size: 0.68rem;
padding: 0.05rem 0.4rem;
border-radius: 999px;
border: 1px solid var(--site-border);
color: var(--site-muted);
white-space: nowrap;
}
.plant :global(.md) {
font-size: 0.9rem;
line-height: 1.55;
overflow-wrap: anywhere;
}
.plant :global(.md > * + *) {
margin-top: 0.55rem;
}
.plant :global(.md h1),
.plant :global(.md h2),
.plant :global(.md h3) {
font-weight: 600;
font-size: 1rem;
margin-top: 1rem;
}
.plant :global(.md ul) {
list-style: disc;
padding-left: 1.2rem;
}
.plant :global(.md ol) {
list-style: decimal;
padding-left: 1.2rem;
}
.plant :global(.md li + li) {
margin-top: 0.2rem;
}
.plant :global(.md code) {
font-size: 0.82rem;
padding: 0.05rem 0.3rem;
border-radius: 0.3rem;
background: color-mix(in srgb, var(--site-text) 8%, transparent);
}
.plant :global(.md pre) {
padding: 0.6rem 0.75rem;
border-radius: 0.5rem;
overflow-x: auto;
background: color-mix(in srgb, var(--site-text) 6%, transparent);
}
.plant :global(.md pre code) {
background: none;
padding: 0;
}
.plant :global(.md a) {
text-decoration: underline;
}
.plant :global(.md strong) {
font-weight: 600;
}
.plant :global(.md blockquote) {
border-left: 2px solid var(--site-border);
padding-left: 0.7rem;
color: var(--site-muted);
}
.plant :global(.md table) {
border-collapse: collapse;
font-size: 0.85rem;
}
.plant :global(.md th),
.plant :global(.md td) {
border: 1px solid var(--site-border);
padding: 0.25rem 0.5rem;
text-align: left;
}
@media (max-width: 640px) {
.plant {
padding-top: 1.25rem;
}
h1 {
font-size: 1.6rem;
}
.search {
flex: 1 1 100%;
}
}
</style>

View File

@@ -0,0 +1,27 @@
import { requireOwner } from '#lib/server/owner.js';
import { plantActions } from '#lib/server/plant-actions.js';
import { renderBody } from '#lib/server/plant.js';
import { client } from '#lib/server/db/index.js';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
requireOwner(event);
// The overview shows the "factory in one picture" note, and notes tagged "overview" (the
// site overview) at the top.
const [intro] = await client`SELECT body FROM plant.entries
WHERE kind = 'note' AND slug = 'factory-overview'`;
const top = await client`SELECT id, body FROM plant.entries
WHERE kind = 'note' AND 'overview' = ANY(tags) AND slug <> 'factory-overview' AND body IS NOT NULL
ORDER BY sort_order, id`;
return {
introHtml: intro?.body ? renderBody(intro.body) : null,
topNotes: top.map((n) => ({ id: n.id as number, html: renderBody(n.body) })),
seo: {
title: 'cargill',
description: 'What the Cargill Bioindustrial plant in Gouda does.',
index: false
}
};
};
export const actions: Actions = plantActions;

View File

@@ -0,0 +1,978 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { relativeTime } from '#lib/relative-time.js';
import KindIcon from '#lib/plant/KindIcon.svelte';
import ConfidencePill from '#lib/plant/ConfidencePill.svelte';
import EntryForm from '#lib/plant/EntryForm.svelte';
import FlowDiagram from '#lib/plant/FlowDiagram.svelte';
import {
KIND_LABEL,
PLANT_KINDS,
entryHref,
kindFromTab,
matches,
tabHref,
type PlantKind
} from '#lib/plant.js';
import Plus from 'lucide-svelte/icons/plus';
import Pencil from 'lucide-svelte/icons/pencil';
import Trash2 from 'lucide-svelte/icons/trash-2';
import CircleCheck from 'lucide-svelte/icons/circle-check';
import CircleDashed from 'lucide-svelte/icons/circle-dashed';
import Link2 from 'lucide-svelte/icons/link-2';
import Workflow from 'lucide-svelte/icons/workflow';
import StickyNote from 'lucide-svelte/icons/sticky-note';
import CircleQuestionMark from 'lucide-svelte/icons/circle-question-mark';
import History from 'lucide-svelte/icons/history';
import Factory from 'lucide-svelte/icons/factory';
let { data } = $props();
type Entry = (typeof data.entries)[number];
const kind = $derived(kindFromTab(page.url.searchParams.get('tab')));
const q = $derived((page.url.searchParams.get('q') ?? '').trim());
/** Which inline form is open: 'new' or the id of the entry being edited. */
let editing = $state<'new' | number | null>(null);
$effect(() => {
void kind;
editing = null;
});
let busy = $state(false);
const confirmSubmit =
(text?: string): SubmitFunction =>
({ cancel }) => {
if (text && !confirm(text)) return cancel();
busy = true;
return async ({ update }) => {
await update({ reset: false, navigate: false });
busy = false;
};
};
const byId = $derived(new Map(data.entries.map((e) => [e.id, e])));
const linkCount = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
const c = new Map<number, number>();
for (const l of data.links) {
c.set(l.fromId, (c.get(l.fromId) ?? 0) + 1);
c.set(l.toId, (c.get(l.toId) ?? 0) + 1);
}
return c;
});
const ofKind = (k: PlantKind) =>
data.entries
.filter((e) => e.kind === k)
.sort((a, b) => a.sortOrder - b.sortOrder || a.title.localeCompare(b.title));
/** Department clusters (FA, EA, DIM, UTIL): tagged "cluster", shown as sections. */
const isCluster = (e: Entry) => e.kind === 'department' && e.tags.includes('cluster');
const byTitle = (a: Entry, b: Entry) => a.title.localeCompare(b.title, 'en', { numeric: true });
const clusters = $derived(
data.entries
.filter((e) => isCluster(e) && !e.parentId)
.sort((a, b) => a.sortOrder - b.sortOrder)
);
/** Every department below a cluster, depth first (MP1, then its 15U units). */
const unitsOf = (c: Entry): Entry[] =>
data.entries
.filter((u) => u.parentId === c.id && u.kind === 'department')
.sort(byTitle)
.flatMap((u) => [u, ...unitsOf(u)]);
/** Units tagged "inactive" are dimmed, and can be hidden. */
const isInactive = (e: Entry) => e.tags.includes('inactive');
let hideInactive = $state(false);
/** Same-kind children (units under a department group like MP1), shown inside the parent card. */
const childrenOf = (e: Entry) =>
data.entries
.filter((c) => c.parentId === e.id && c.kind === e.kind && !(hideInactive && isInactive(c)))
.sort(byTitle);
const results = $derived(q ? data.entries.filter((e) => matches(e, q)) : []);
const confidence = $derived.by(() => {
const c = { confirmed: 0, assumed: 0, open: 0 };
for (const e of data.entries) c[e.confidence]++;
return c;
});
const questions = $derived(ofKind('question'));
const openQuestions = $derived(questions.filter((e) => !e.answeredAt));
const recent = $derived(
[...data.entries].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 6)
);
// Glossary: A–Z with a filter.
let filter = $state('');
const letterOf = (t: string) => {
const c = t
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.charAt(0)
.toUpperCase();
return /[A-Z]/.test(c) ? c : '#';
};
const glossary = $derived(
ofKind('glossary')
.filter((e) => matches(e, filter))
.sort((a, b) => a.title.localeCompare(b.title, 'en', { sensitivity: 'base' }))
);
const letters = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
const g = new Map<string, Entry[]>();
for (const e of glossary) g.set(letterOf(e.title), [...(g.get(letterOf(e.title)) ?? []), e]);
return g;
});
const AZ = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'];
</script>
{#snippet tools(e: Entry, withEdit = true)}
<span class="tools">
{#if withEdit}<button
type="button"
class="icon"
aria-label="edit {e.title}"
title="edit"
onclick={() => (editing = editing === e.id ? null : e.id)}><Pencil size={14} /></button
>{/if}
<form
method="POST"
action="?/delete"
use:enhance={confirmSubmit(`Delete “${e.title}” and its links?`)}
>
<input type="hidden" name="id" value={e.id} />
<button type="submit" class="icon danger" aria-label="delete {e.title}" title="delete"
><Trash2 size={14} /></button
>
</form>
</span>
{/snippet}
{#snippet answerToggle(e: Entry)}
<form method="POST" action="?/answer" use:enhance={confirmSubmit()}>
<input type="hidden" name="id" value={e.id} />
<input type="hidden" name="answered" value={e.answeredAt ? '0' : '1'} />
<button
type="submit"
class={['toggle', { done: e.answeredAt }]}
disabled={busy}
aria-pressed={!!e.answeredAt}
title={e.answeredAt ? 'answered: mark as open again' : 'mark as answered'}
aria-label={e.answeredAt ? `reopen ${e.title}` : `mark ${e.title} answered`}
>{#if e.answeredAt}<CircleCheck size={18} />{:else}<CircleDashed size={18} />{/if}</button
>
</form>
{/snippet}
{#snippet row(e: Entry, showKind = false)}
{#if editing === e.id}
<EntryForm entry={e} entries={data.entries} onclose={() => (editing = null)} />
{:else}
<div class={['row', { answered: e.answeredAt, inactive: isInactive(e) }]}>
{#if e.kind === 'question'}{@render answerToggle(e)}{:else if showKind}<KindIcon
kind={e.kind}
size={20}
/>{/if}
<div class="row-main">
<a class="row-title" href={resolve(entryHref(e) as '/')}>{e.title}</a>
{#if e.summary}<span class="row-summary">{e.summary}</span>{/if}
</div>
{#if e.kind !== 'glossary' || e.confidence !== 'confirmed'}<ConfidencePill
value={e.confidence}
/>{/if}
{@render tools(e)}
</div>
{/if}
{/snippet}
{#snippet card(e: Entry)}
{#if editing === e.id}
<div class="span-all">
<EntryForm entry={e} entries={data.entries} onclose={() => (editing = null)} />
</div>
{:else}
{@const parent = e.parentId ? byId.get(e.parentId) : null}
{@const kids = childrenOf(e)}
<article class={['card entry-card', { inactive: isInactive(e), group: kids.length }]}>
<header>
<KindIcon kind={e.kind} size={30} />
<div class="ec-title">
<a href={resolve(entryHref(e) as '/')}>{e.title}</a>
{#if parent && parent.kind !== e.kind}<span class="ec-parent"
>in <a href={resolve(entryHref(parent) as '/')}>{parent.title}</a></span
>{/if}
</div>
{@render tools(e)}
</header>
{#if e.summary}<p class="ec-summary">{e.summary}</p>{/if}
<footer>
<ConfidencePill value={e.confidence} />
{#if linkCount.get(e.id)}<span class="tag links"
><Link2 size={11} />{linkCount.get(e.id)}</span
>{/if}
{#each e.tags as t (t)}<span class={['tag', { 'tag-inactive': t === 'inactive' }]}>{t}</span
>{/each}
</footer>
{#if kids.length}
<ul class="kids" aria-label="units in {e.title}">
{#each kids as k (k.id)}
{#if editing === k.id}
<li class="kid-edit">
<EntryForm entry={k} entries={data.entries} onclose={() => (editing = null)} />
</li>
{:else}
<li class={['row', { inactive: isInactive(k) }]}>
<KindIcon kind={k.kind} size={18} />
<div class="row-main">
<a class="row-title" href={resolve(entryHref(k) as '/')}>{k.title}</a>
{#if k.summary}<span class="row-summary">{k.summary}</span>{/if}
<span class="kid-meta">
<ConfidencePill value={k.confidence} />
{#if isInactive(k)}<span class="tag tag-inactive">inactive</span>{/if}
</span>
</div>
{@render tools(k)}
</li>
{/if}
{/each}
</ul>
{/if}
</article>
{/if}
{/snippet}
{#snippet addBar(k: PlantKind | null, meta: string)}
<div class="bar">
<p class="meta">{meta}</p>
<button
type="button"
class="primary"
aria-expanded={editing === 'new'}
onclick={() => (editing = editing === 'new' ? null : 'new')}
><Plus size={15} />add {k ? KIND_LABEL[k].one : 'entry'}</button
>
</div>
{#if editing === 'new'}
<EntryForm
kind={k ?? 'note'}
entries={data.entries}
onclose={() => (editing = null)}
openAfterCreate={!k}
/>
{/if}
{/snippet}
{#if q}
<p class="meta">
{results.length}
{results.length === 1 ? 'match' : 'matches'} for “{q}”
</p>
{#if results.length}
<div class="card rows">
{#each results as e (e.id)}{@render row(e, true)}{/each}
</div>
{:else}
<p class="empty">Nothing found. Try another word, or add it as a question.</p>
{/if}
{:else if !kind}
{#each data.topNotes as n (n.id)}
{@const note = byId.get(n.id)}
{#if note}
<section class="card site-note">
<h2 class="card-title">
<StickyNote size={14} />{note.title}
<a class="more" href={resolve(entryHref(note) as '/')}>open</a>
</h2>
<!-- Safe: rendered server-side by #lib/server/plant (raw HTML escaped). -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="md">{@html n.html}</div>
</section>
{/if}
{/each}
{@render addBar(
null,
`${data.entries.length} entries · ${confidence.confirmed} confirmed, ${confidence.assumed} assumed, ${confidence.open} open`
)}
<div class="kinds">
{#each PLANT_KINDS as k (k)}
{@const n = data.entries.filter((e) => e.kind === k).length}
{#if n || k !== 'person_role'}
<a class="kind-stat" href={resolve(tabHref(k) as '/')}>
<KindIcon kind={k} size={26} />
<span class="ks-n">{n}</span>
<span class="ks-l">{KIND_LABEL[k].many}</span>
</a>
{/if}
{/each}
</div>
{#if clusters.length}
<section class="card clusters-card">
<h2 class="card-title">
<Factory size={14} />departments by cluster
<a class="more" href={resolve(tabHref('department') as '/')}
>all {ofKind('department').length}</a
>
</h2>
<div class="clusters">
{#each clusters as c (c.id)}
<div class="cluster-mini">
<a class="cm-title" href={resolve(entryHref(c) as '/')}
><KindIcon kind="department" size={20} />{c.title}</a
>
{#if c.summary}<p class="cm-summary">{c.summary}</p>{/if}
<div class="chips">
{#each unitsOf(c) as u (u.id)}
<a
class={['chip', { inactive: isInactive(u) }]}
href={resolve(entryHref(u) as '/')}
title={u.summary ?? u.title}>{u.title}</a
>
{/each}
</div>
</div>
{/each}
</div>
</section>
{/if}
<div class="overview">
<section class="card flow-card">
<h2 class="card-title"><Workflow size={14} />how it flows</h2>
<FlowDiagram entries={data.entries} links={data.links} />
<p class="legend">
<span><i class="lg step"></i>process step</span>
<span><i class="lg terminal"></i>in / out</span>
<span><i class="lg dashed"></i>optional or loop</span>
</p>
</section>
<div class="side">
{#if data.introHtml}
<section class="card">
<h2 class="card-title"><StickyNote size={14} />in one picture</h2>
<!-- Safe: rendered server-side by #lib/server/plant (raw HTML escaped). -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="md">{@html data.introHtml}</div>
</section>
{/if}
<section class="card">
<h2 class="card-title">
<CircleQuestionMark size={14} />open questions
<a class="more" href={resolve(tabHref('question') as '/')}>all {questions.length}</a>
</h2>
{#if openQuestions.length}
<div class="rows">
{#each openQuestions as e (e.id)}{@render row(e)}{/each}
</div>
{:else}
<p class="meta">All answered.</p>
{/if}
</section>
<section class="card">
<h2 class="card-title"><History size={14} />recently updated</h2>
<ul class="recent">
{#each recent as e (e.id)}
<li>
<KindIcon kind={e.kind} size={18} />
<a href={resolve(entryHref(e) as '/')}>{e.title}</a>
<time datetime={e.updatedAt}>{relativeTime(e.updatedAt, data.now)}</time>
</li>
{/each}
</ul>
</section>
</div>
</div>
{:else if kind === 'glossary'}
{@render addBar(kind, `${ofKind('glossary').length} words, A–Z`)}
<div class="gloss-tools">
<input
class="filter"
type="search"
placeholder="filter words"
aria-label="Filter the glossary"
bind:value={filter}
/>
<nav class="az" aria-label="Jump to letter">
{#each AZ as l (l)}
{#if letters.has(l)}<a href="#letter-{l}">{l}</a>{:else}<span>{l}</span>{/if}
{/each}
</nav>
</div>
{#if !glossary.length}<p class="empty">No words match.</p>{/if}
<div class="gloss">
{#each [...letters] as [l, words] (l)}
<section class="card letter" id="letter-{l}">
<h2 class="big-letter">{l}</h2>
<div class="rows">
{#each words as e (e.id)}{@render row(e)}{/each}
</div>
</section>
{/each}
</div>
{:else if kind === 'question'}
{@render addBar(
kind,
`${openQuestions.length} open · ${questions.length - openQuestions.length} answered`
)}
<section class="card rows">
{#each openQuestions as e (e.id)}{@render row(e)}{:else}<p class="meta">
No open questions.
</p>{/each}
</section>
{#if questions.length > openQuestions.length}
<h2 class="sub">answered</h2>
<section class="card rows">
{#each questions.filter((e) => e.answeredAt) as e (e.id)}{@render row(e)}{/each}
</section>
{/if}
{:else}
{@const list = ofKind(kind)}
{@const inactiveCount = list.filter(isInactive).length}
{@const top = list.filter(
(e) => (!e.parentId || byId.get(e.parentId)?.kind !== kind) && !(hideInactive && isInactive(e))
)}
{@render addBar(
kind,
`${list.length} ${KIND_LABEL[kind].many}${inactiveCount ? ` · ${inactiveCount} inactive` : ''}`
)}
{#if inactiveCount}
<label class="switch">
<input type="checkbox" bind:checked={hideInactive} />
<span class="knob" aria-hidden="true"></span>hide inactive
</label>
{/if}
{#if kind === 'process_step'}
<section class="card flow-card">
<FlowDiagram entries={data.entries} links={data.links} />
</section>
{/if}
{#if !list.length}
<p class="empty">
Nothing here yet.{kind === 'person_role'
? ' Add roles (operator, shift lead, lab analyst …), not names.'
: ''}
</p>
{/if}
{@const groups = top.filter(isCluster)}
{#if groups.length}
{#each groups as c (c.id)}
{@const units = childrenOf(c)}
<section class={['cluster', { inactive: isInactive(c) }]} aria-labelledby="cluster-{c.id}">
<header class="cluster-head">
<KindIcon kind={c.kind} size={26} />
<div class="ch-text">
<h2 id="cluster-{c.id}"><a href={resolve(entryHref(c) as '/')}>{c.title}</a></h2>
{#if c.summary}<p class="ec-summary">{c.summary}</p>{/if}
</div>
<span class="tag">{units.length} units</span>
<ConfidencePill value={c.confidence} />
{@render tools(c)}
</header>
{#if editing === c.id}
<EntryForm entry={c} entries={data.entries} onclose={() => (editing = null)} />
{/if}
<div class="grid">
{#each units as e (e.id)}{@render card(e)}{/each}
</div>
</section>
{/each}
{@const rest = top.filter((e) => !isCluster(e))}
{#if rest.length}
<section class="cluster" aria-labelledby="cluster-none">
<header class="cluster-head">
<div class="ch-text">
<h2 id="cluster-none">not in a cluster</h2>
<p class="ec-summary">General entries, or units still to place.</p>
</div>
</header>
<div class="grid">
{#each rest as e (e.id)}{@render card(e)}{/each}
</div>
</section>
{/if}
{:else}
<div class="grid">
{#each [...top].sort((a, b) => childrenOf(b).length - childrenOf(a).length) as e (e.id)}{@render card(
e
)}{/each}
</div>
{/if}
{/if}
<style>
.kinds {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
gap: 0.5rem;
}
.kind-stat {
display: grid;
grid-template-columns: auto 1fr;
grid-template-areas: 'icon n' 'icon l';
column-gap: 0.55rem;
align-items: center;
padding: 0.5rem 0.65rem;
border: 1px solid var(--site-border);
border-radius: 0.7rem;
background: var(--site-surface);
text-decoration: none;
transition: border-color 0.15s;
}
.kind-stat:hover {
border-color: var(--site-muted);
}
.kind-stat :global(.kind-icon) {
grid-area: icon;
}
.ks-n {
grid-area: n;
font-weight: 600;
font-size: 1.05rem;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.ks-l {
grid-area: l;
font-size: 0.72rem;
color: var(--site-muted);
}
.overview {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
gap: 0.75rem;
align-items: start;
}
.side {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
}
.flow-card {
overflow-x: auto;
}
.legend {
display: flex;
gap: 0.9rem;
flex-wrap: wrap;
justify-content: center;
margin-top: 0.6rem;
font-size: 0.72rem;
color: var(--site-muted);
}
.legend span {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.lg {
display: inline-block;
width: 1.3rem;
height: 0.75rem;
border: 1.2px solid var(--site-muted);
border-radius: 0.25rem;
}
.lg.terminal {
border-style: dashed;
border-radius: 999px;
}
.lg.dashed {
height: 0;
border-width: 1.2px 0 0;
border-style: dashed;
border-radius: 0;
}
.more {
margin-left: auto;
text-transform: none;
letter-spacing: normal;
font-size: 0.75rem;
color: var(--site-muted);
}
.more:hover {
color: var(--site-text);
}
.recent {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.recent li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
min-width: 0;
}
.recent a {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recent a:hover,
.row-title:hover,
.ec-title a:hover {
text-decoration: underline;
}
.recent time {
margin-left: auto;
font-size: 0.72rem;
color: var(--site-muted);
white-space: nowrap;
}
.rows {
display: flex;
flex-direction: column;
}
.card.rows {
padding: 0.35rem 0.75rem;
}
.row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0;
min-width: 0;
}
.rows > :global(* + *) {
border-top: 1px dashed color-mix(in srgb, var(--site-border) 70%, transparent);
}
.rows > :global(form.edit) {
margin: 0.4rem 0;
}
.row-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.row-title {
font-weight: 500;
font-size: 0.9rem;
}
.row-summary {
font-size: 0.8rem;
color: var(--site-muted);
line-height: 1.35;
}
.row.answered .row-title {
color: var(--site-muted);
text-decoration: line-through;
text-decoration-color: color-mix(in srgb, var(--site-muted) 60%, transparent);
}
.tools {
display: inline-flex;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
}
.row:hover .tools,
.row:focus-within .tools,
.entry-card:hover .tools,
.entry-card:focus-within .tools,
.cluster-head:hover .tools,
.cluster-head:focus-within .tools {
opacity: 1;
}
@media (hover: none) {
.tools {
opacity: 1;
}
}
.toggle {
display: inline-flex;
color: #ec4899;
padding: 0.1rem;
border-radius: 999px;
}
.toggle.done {
color: #10b981;
}
.toggle:hover {
background: color-mix(in srgb, var(--site-text) 8%, transparent);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));
gap: 0.6rem;
align-items: start;
}
.span-all {
grid-column: 1 / -1;
}
.entry-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
transition: border-color 0.15s;
}
.entry-card:hover {
border-color: var(--site-muted);
}
.entry-card header {
display: flex;
align-items: center;
gap: 0.6rem;
}
.ec-title {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.ec-title > a {
font-weight: 600;
line-height: 1.2;
}
.ec-parent {
font-size: 0.72rem;
color: var(--site-muted);
}
.ec-summary {
font-size: 0.83rem;
color: var(--site-muted);
line-height: 1.4;
}
.entry-card footer {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-items: center;
margin-top: auto;
}
.entry-card.inactive,
.row.inactive {
opacity: 0.5;
}
.entry-card.inactive :global(.kind-icon),
.row.inactive :global(.kind-icon) {
filter: grayscale(1);
}
.entry-card.inactive:hover,
.row.inactive:hover {
opacity: 0.85;
}
.tag-inactive {
border-style: dashed;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.kid-meta {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.3rem;
}
.clusters {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 15rem), 1fr));
gap: 0.5rem;
}
.cluster-mini {
border: 1px solid var(--site-border);
border-radius: 0.7rem;
padding: 0.55rem 0.65rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.cm-title {
display: flex;
align-items: center;
gap: 0.45rem;
font-weight: 600;
font-size: 0.9rem;
}
.cm-summary {
margin: 0;
font-size: 0.78rem;
color: var(--site-muted);
line-height: 1.35;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.chip {
font-size: 0.72rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
border: 1px solid var(--site-border);
white-space: nowrap;
}
.chip:hover {
border-color: var(--site-text);
}
.chip.inactive {
opacity: 0.45;
border-style: dashed;
}
.cluster {
display: flex;
flex-direction: column;
gap: 0.55rem;
margin-top: 0.5rem;
}
.cluster-head {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
padding-bottom: 0.45rem;
border-bottom: 1px solid var(--site-border);
}
.ch-text {
flex: 1;
min-width: 12rem;
}
.ch-text h2 {
margin: 0;
font-size: 1rem;
}
.ch-text .ec-summary {
margin: 0.1rem 0 0;
}
.entry-card.group {
grid-column: 1 / -1;
}
.kids {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 17rem), 1fr));
gap: 0.4rem;
border-top: 1px solid var(--site-border);
padding-top: 0.55rem;
}
.kids .row {
padding: 0.4rem 0.55rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
align-items: flex-start;
}
.kid-edit {
grid-column: 1 / -1;
}
.kids .row-title {
font-size: 0.85rem;
}
.switch {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.8rem;
color: var(--site-muted);
cursor: pointer;
align-self: flex-start;
}
.switch input {
position: absolute;
opacity: 0;
}
.knob {
position: relative;
width: 1.9rem;
height: 1.05rem;
border-radius: 999px;
background: var(--site-border);
transition: background 0.15s;
}
.knob::after {
content: '';
position: absolute;
top: 0.15rem;
left: 0.15rem;
width: 0.75rem;
height: 0.75rem;
border-radius: 999px;
background: var(--site-bg);
transition: transform 0.15s;
}
.switch input:checked + .knob {
background: var(--site-text);
}
.switch input:checked + .knob::after {
transform: translateX(0.85rem);
}
.switch input:focus-visible + .knob {
outline: 2px solid #38bdf8;
outline-offset: 2px;
}
.tag.links {
display: inline-flex;
align-items: center;
gap: 0.2rem;
}
.gloss-tools {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
}
.filter {
flex: 0 1 14rem;
height: 2rem;
padding: 0 0.7rem;
font: inherit;
font-size: 0.85rem;
border: 1px solid var(--site-border);
border-radius: 999px;
background: var(--site-surface);
color: var(--site-text);
}
.az {
display: flex;
flex-wrap: wrap;
gap: 0.1rem;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.az a,
.az span {
display: inline-flex;
justify-content: center;
width: 1.35rem;
padding: 0.1rem 0;
border-radius: 0.3rem;
}
.az a {
font-weight: 600;
}
.az a:hover {
background: color-mix(in srgb, var(--site-text) 10%, transparent);
}
.az span {
color: var(--site-muted);
opacity: 0.45;
}
.gloss {
columns: 2 22rem;
column-gap: 0.6rem;
}
.letter {
break-inside: avoid;
margin-bottom: 0.6rem;
display: grid;
grid-template-columns: 2rem 1fr;
gap: 0.25rem;
padding: 0.35rem 0.75rem;
scroll-margin-top: 5rem;
}
.big-letter {
font-size: 1.3rem;
font-weight: 700;
padding-top: 0.35rem;
background: linear-gradient(135deg, #6ee7b7, #047857);
background-clip: text;
color: transparent;
}
.sub {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
}
@media (max-width: 860px) {
.overview {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@@ -0,0 +1,34 @@
import { kindFromSegment } from '#lib/plant.js';
import { requireOwner } from '#lib/server/owner.js';
import { plantActions } from '#lib/server/plant-actions.js';
import { renderBody } from '#lib/server/plant.js';
import { client } from '#lib/server/db/index.js';
import { error } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
// One entry: its markdown body, parent, children and links (entries and links come from the
// layout load).
export const load: PageServerLoad = async (event) => {
requireOwner(event);
const kind = kindFromSegment(event.params.kind);
if (!kind) error(404, 'Not found');
const [row] = await client`SELECT id, title, summary, body FROM plant.entries
WHERE kind = ${kind} AND slug = ${event.params.slug}`;
if (!row) error(404, 'Not found');
// Parents and links whose target isn't in the tracker yet (a later import resolves them).
const pending = await client`SELECT id, field, to_ref, label FROM plant.pending
WHERE from_id = ${row.id} ORDER BY id`;
return {
entryId: row.id as number,
pending: pending.map((p) => ({
id: p.id as number,
field: p.field as string,
to: p.to_ref as string,
label: p.label as string | null
})),
bodyHtml: row.body ? renderBody(row.body) : null,
seo: { title: `${row.title} · cargill`, description: row.summary ?? '', index: false }
};
};
export const actions: Actions = plantActions;

View File

@@ -0,0 +1,512 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { resolve } from '$app/paths';
import { relativeTime } from '#lib/relative-time.js';
import KindIcon from '#lib/plant/KindIcon.svelte';
import ConfidencePill from '#lib/plant/ConfidencePill.svelte';
import EntryForm from '#lib/plant/EntryForm.svelte';
import {
KIND_LABEL,
PLANT_KINDS,
RELATIONS,
RELATION_LABEL,
entryHref,
tabHref,
type Relation
} from '#lib/plant.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import Pencil from 'lucide-svelte/icons/pencil';
import Trash2 from 'lucide-svelte/icons/trash-2';
import CircleCheck from 'lucide-svelte/icons/circle-check';
import CircleDashed from 'lucide-svelte/icons/circle-dashed';
import Link2 from 'lucide-svelte/icons/link-2';
import Unlink from 'lucide-svelte/icons/unlink';
import Plus from 'lucide-svelte/icons/plus';
let { data, form } = $props();
type Entry = (typeof data.entries)[number];
const byId = $derived(new Map(data.entries.map((e) => [e.id, e])));
const entry = $derived(byId.get(data.entryId)!);
const parentChain = $derived.by(() => {
const chain: Entry[] = [];
let p = entry?.parentId ? byId.get(entry.parentId) : undefined;
while (p && chain.length < 5 && !chain.includes(p)) {
chain.unshift(p);
p = p.parentId ? byId.get(p.parentId) : undefined;
}
return chain;
});
const children = $derived(
data.entries
.filter((e) => e.parentId === data.entryId)
.sort((a, b) => a.sortOrder - b.sortOrder || a.title.localeCompare(b.title))
);
/** Links grouped by how they read from this entry ("feeds into", "fed by", ...). */
const linkGroups = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
const groups = new Map<string, { link: (typeof data.links)[number]; other: Entry }[]>();
for (const relation of RELATIONS)
for (const dir of ['out', 'in'] as const) {
const label = RELATION_LABEL[relation][dir];
for (const l of data.links) {
if (l.relation !== relation) continue;
const mine = dir === 'out' ? l.fromId : l.toId;
if (mine !== data.entryId) continue;
const other = byId.get(dir === 'out' ? l.toId : l.fromId);
if (other) groups.set(label, [...(groups.get(label) ?? []), { link: l, other }]);
}
}
return [...groups];
});
const linkTotal = $derived(linkGroups.reduce((s, [, g]) => s + g.length, 0));
let editing = $state(false);
let linking = $state(false);
$effect(() => {
void data.entryId;
editing = false;
linking = false;
});
let busy = $state(false);
let linkError = $state<string | null>(null);
const submit =
(confirmText?: string, after?: () => void): SubmitFunction =>
({ cancel }) => {
if (confirmText && !confirm(confirmText)) return cancel();
busy = true;
linkError = null;
return async ({ result, update }) => {
await update({ reset: false, navigate: false });
busy = false;
if (result.type === 'failure') linkError = String(result.data?.message ?? 'Failed');
if (result.type === 'success') after?.();
};
};
let reverse = $state(false);
let relation = $state<Relation>('related');
const stamp = (iso: string) =>
new Date(iso).toLocaleString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Amsterdam'
});
</script>
{#if entry}
<nav class="crumbs" aria-label="Breadcrumb">
<a href={resolve(tabHref(entry.kind) as '/')}
><ArrowLeft size={14} />{KIND_LABEL[entry.kind].many}</a
>
{#each parentChain as p (p.id)}
<span>/</span><a href={resolve(entryHref(p) as '/')}>{p.title}</a>
{/each}
</nav>
<div class="layout">
<article class="card main">
<header class="head">
<KindIcon kind={entry.kind} size={40} />
<div class="head-text">
<p class="kind">{KIND_LABEL[entry.kind].one}</p>
<h2 class={{ answered: entry.answeredAt }}>{entry.title}</h2>
</div>
<div class="tools">
<button
type="button"
class="icon"
aria-label="edit"
title="edit"
aria-expanded={editing}
onclick={() => (editing = !editing)}><Pencil size={16} /></button
>
<form
method="POST"
action="?/delete"
use:enhance={submit(`Delete “${entry.title}” and its links?`)}
>
<input type="hidden" name="id" value={entry.id} />
<input type="hidden" name="redirect" value="1" />
<button type="submit" class="icon danger" aria-label="delete" title="delete"
><Trash2 size={16} /></button
>
</form>
</div>
</header>
<div class="badges">
<ConfidencePill value={entry.confidence} />
{#if entry.kind === 'question'}
<form method="POST" action="?/answer" use:enhance={submit()}>
<input type="hidden" name="id" value={entry.id} />
<input type="hidden" name="answered" value={entry.answeredAt ? '0' : '1'} />
<button
type="submit"
class={['answer', { done: entry.answeredAt }]}
disabled={busy}
aria-pressed={!!entry.answeredAt}
>{#if entry.answeredAt}<CircleCheck size={14} />answered {relativeTime(
entry.answeredAt,
data.now
)}{:else}<CircleDashed size={14} />mark answered{/if}</button
>
</form>
{/if}
{#each entry.tags as t (t)}<span class={['tag', { 'tag-inactive': t === 'inactive' }]}
>{t}</span
>{/each}
</div>
{#if editing}
<EntryForm {entry} entries={data.entries} onclose={() => (editing = false)} />
{:else}
{#if entry.summary}<p class="summary">{entry.summary}</p>{/if}
{#if data.bodyHtml}
<!-- Safe: rendered server-side by #lib/server/plant (raw HTML escaped). -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="md">{@html data.bodyHtml}</div>
{:else if !entry.summary}
<p class="meta">No details yet.</p>
{/if}
{/if}
<footer class="foot">
{#if entry.source}<span>source: {entry.source}</span>{/if}
<time datetime={entry.updatedAt} title="{stamp(entry.updatedAt)} (Amsterdam)"
>updated {relativeTime(entry.updatedAt, data.now)}</time
>
</footer>
</article>
<aside class="side">
<section class="card">
<h3 class="card-title">
<Link2 size={14} />linked {linkTotal ? `(${linkTotal})` : ''}
<button
type="button"
class="icon add-link"
aria-label="add a link"
title="add a link"
aria-expanded={linking}
onclick={() => (linking = !linking)}><Plus size={15} /></button
>
</h3>
{#if linking}
<form
class="link-form"
method="POST"
action="?/link"
use:enhance={submit(undefined, () => (linking = false))}
>
<input type="hidden" name={reverse ? 'to_id' : 'from_id'} value={entry.id} />
<div class="lf-row">
<select bind:value={reverse} aria-label="direction">
<option value={false}>this → other</option>
<option value={true}>other → this</option>
</select>
<select name="relation" bind:value={relation} aria-label="relation">
{#each RELATIONS as r (r)}<option value={r}>{RELATION_LABEL[r].out}</option>{/each}
</select>
</div>
<select name={reverse ? 'from_id' : 'to_id'} required aria-label="other entry">
<option value="">pick an entry…</option>
{#each PLANT_KINDS as k (k)}
{@const options = data.entries.filter((e) => e.kind === k && e.id !== entry.id)}
{#if options.length}
<optgroup label={KIND_LABEL[k].many}>
{#each options as o (o.id)}<option value={o.id}>{o.title}</option>{/each}
</optgroup>
{/if}
{/each}
</select>
<div class="lf-row">
<input name="label" maxlength="80" placeholder="label (optional)" />
<label class="check"><input type="checkbox" name="dashed" value="1" />dashed</label>
</div>
<div class="lf-row end">
<button type="button" class="ghost" onclick={() => (linking = false)}>cancel</button>
<button type="submit" class="primary" disabled={busy}>link</button>
</div>
{#if linkError}<p class="error" role="alert">{linkError}</p>{/if}
</form>
{/if}
{#if form?.message && !linking && !editing}<p class="error" role="alert">
{form.message}
</p>{/if}
{#each linkGroups as [label, items] (label)}
<div class="group">
<p class="group-label">{label}</p>
<ul>
{#each items as { link, other } (link.id)}
<li>
<KindIcon kind={other.kind} size={20} />
<a href={resolve(entryHref(other) as '/')}>{other.title}</a>
{#if link.label}<span class="tag">{link.label}</span>{/if}
{#if link.confidence !== 'confirmed'}<span
class="tag maybe"
title="{link.confidence} link, not confirmed yet">?</span
>{/if}
<form method="POST" action="?/unlink" use:enhance={submit()}>
<input type="hidden" name="id" value={link.id} />
<button
type="submit"
class="icon danger unlink"
aria-label="unlink {other.title}"
title="unlink"><Unlink size={13} /></button
>
</form>
</li>
{/each}
</ul>
</div>
{:else}
{#if !linking && !data.pending.length}<p class="meta">No links yet.</p>{/if}
{/each}
{#if data.pending.length}
<div class="group pending">
<p class="group-label">pending</p>
<ul>
{#each data.pending as p (p.id)}
<li title="Not in the tracker yet; resolves when an entry with this slug is added">
<span class="pending-dot" aria-hidden="true"></span>
<span class="pending-ref">{p.to}</span>
<span class="tag"
>{p.field === 'parent'
? 'part of'
: (RELATION_LABEL[p.field as Relation]?.out ?? p.field)}</span
>
</li>
{/each}
</ul>
</div>
{/if}
</section>
{#if children.length}
<section class="card">
<h3 class="card-title">contains ({children.length})</h3>
<ul class="group">
{#each children as c (c.id)}
<li>
<KindIcon kind={c.kind} size={20} />
<a href={resolve(entryHref(c) as '/')}>{c.title}</a>
</li>
{/each}
</ul>
</section>
{/if}
</aside>
</div>
{/if}
<style>
.crumbs {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
font-size: 0.83rem;
color: var(--site-muted);
}
.crumbs a {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.crumbs a:hover {
color: var(--site-text);
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 20rem);
gap: 0.75rem;
align-items: start;
}
.main {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.1rem;
}
.head {
display: flex;
align-items: center;
gap: 0.75rem;
}
.head-text {
flex: 1;
min-width: 0;
}
.kind {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
}
h2 {
font-size: 1.45rem;
font-weight: 600;
letter-spacing: -0.015em;
line-height: 1.15;
}
h2.answered {
text-decoration: line-through;
text-decoration-color: color-mix(in srgb, var(--site-muted) 60%, transparent);
}
.tools {
display: flex;
gap: 0.1rem;
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.tag-inactive {
border-style: dashed;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.answer {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
padding: 0.1rem 0.55rem;
border-radius: 999px;
border: 1px solid rgb(236 72 153 / 0.4);
color: #ec4899;
}
.answer.done {
border-color: rgb(16 185 129 / 0.4);
color: #10b981;
}
.summary {
font-size: 1rem;
line-height: 1.45;
}
.foot {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1rem;
font-size: 0.72rem;
color: var(--site-muted);
border-top: 1px solid var(--site-border);
padding-top: 0.6rem;
}
.side {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
}
.add-link {
margin-left: auto;
}
.group + .group {
margin-top: 0.6rem;
}
.maybe {
border-style: dashed;
color: #f59e0b;
font-weight: 700;
}
.pending li {
opacity: 0.7;
}
.pending-dot {
width: 20px;
height: 20px;
border-radius: 6px;
border: 1px dashed var(--site-muted);
flex-shrink: 0;
}
.pending-ref {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.8rem;
}
.group-label {
font-size: 0.72rem;
color: var(--site-muted);
margin-bottom: 0.2rem;
}
.group li,
ul.group li {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.2rem 0;
font-size: 0.86rem;
min-width: 0;
}
.group li a {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group li a:hover {
text-decoration: underline;
}
.group li form {
margin-left: auto;
}
.unlink {
opacity: 0;
}
.group li:hover .unlink,
.unlink:focus-visible {
opacity: 1;
}
@media (hover: none) {
.unlink {
opacity: 1;
}
}
.link-form {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--site-border);
}
.lf-row {
display: flex;
gap: 0.4rem;
align-items: center;
}
.lf-row.end {
justify-content: flex-end;
}
.link-form select,
.link-form input:not([type='checkbox']) {
font: inherit;
font-size: 0.83rem;
height: 2rem;
padding: 0 0.45rem;
border: 1px solid var(--site-border);
border-radius: 0.45rem;
background: var(--site-bg);
color: var(--site-text);
min-width: 0;
flex: 1;
}
.check {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--site-muted);
}
.error {
color: #f87171;
font-size: 0.8rem;
}
@media (max-width: 860px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
}
</style>