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

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,
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',
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 './notes';
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 notesSchema = pgSchema('notes');
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(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', () => {
expect(isOwnerWritePath('/trackers/books/12')).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.
'/projects/worldwide-todo/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_PRIVATE ? (['/trackers/math'] as const) : [])
] as const;
@@ -71,7 +73,8 @@ export const ownerWritePrefixes = [
'/trackers/notes',
'/trackers/ai',
'/trackers/movies',
'/trackers/math'
'/trackers/math',
'/trackers/cargill'
] as const;
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'
]
},
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: {
from: '#60a5fa',
to: '#1e40af',