plant: private Cargill Gouda tracker (/trackers/cargill)
All checks were successful
Deploy production / deploy (push) Successful in 1m19s
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:
79
src/lib/plant.test.ts
Normal file
79
src/lib/plant.test.ts
Normal 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
184
src/lib/plant.ts
Normal 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));
|
||||
}
|
||||
46
src/lib/plant/ConfidencePill.svelte
Normal file
46
src/lib/plant/ConfidencePill.svelte
Normal 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>
|
||||
242
src/lib/plant/EntryForm.svelte
Normal file
242
src/lib/plant/EntryForm.svelte
Normal 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>
|
||||
256
src/lib/plant/FlowDiagram.svelte
Normal file
256
src/lib/plant/FlowDiagram.svelte
Normal 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>
|
||||
51
src/lib/plant/KindIcon.svelte
Normal file
51
src/lib/plant/KindIcon.svelte
Normal 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>
|
||||
@@ -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",
|
||||
|
||||
@@ -34,3 +34,4 @@ export * from './worldtodo';
|
||||
export * from './osu-goals';
|
||||
export * from './notes';
|
||||
export * from './math';
|
||||
export * from './plant';
|
||||
|
||||
118
src/lib/server/db/schema/plant.ts
Normal file
118
src/lib/server/db/schema/plant.ts
Normal 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')`
|
||||
)
|
||||
]
|
||||
);
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(/\/+$/, '');
|
||||
|
||||
27
src/lib/server/plant-actions.ts
Normal file
27
src/lib/server/plant-actions.ts
Normal 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
232
src/lib/server/plant.ts
Normal 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 };
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user