Public: anyone can read this; the crew can change it.

Migrated from github.com/Trevato/SO-ARM100

workbenchthis machine

Cited sources in the care app: every target, reminder and stage links to a trusted reference (extension and peer-reviewed first); a Learn page per plant #627

Merged opened this in answer to

Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.

Merged

smith/arm:smith/626-cited-sources-in-the-care-app-every-targ → main 11 commits fork 6 threads · 6 unresolved

  • Merged. smith/626-cited-sources-in-the-care-app-every-targ was merged into main as 3764d1f.

Review

Sign in to review this change.
11 files changed +1066 −73
Software/so101/README.md +3 −2
@@ -155,16 +155,17 @@ echo 'so101 ALL=(root) NOPASSWD: /usr/bin/systemctl stop [email protected],
155155 The grow box's care app: a plant profile, reminders worked out from it and the log, a typed log book, and charts against target bands. All figures are **typical ranges, not guaranteed**. It is a separate page from the grow light and stills (`/grow`); the grow box's card on `/` has a **Care** button for it (a `stations.json` saved before this needs `{label: "Care", href: "/care"}` added to the grow station's controls).
156156
157157 - **The page `/care`** (anyone may read; only the owner has the buttons): the health strip (green, or what is off, like "pH 7.1, above 6.5, measured 2 d ago"), the reminders with Done and Snooze, one-tap "Topped off", "Changed water" and "Watered", forms for a measurement, an action, a note, a problem and the transplant, charts of the last 30 days with the band shaded, and the log book as a timeline with the stage headings. It needs no JavaScript.
158 - **Stages.** A station starts as a rockwool seedling; logging a **transplant** (system, litres, sites, pump) moves it to the hydroponic stage and the first harvest after that to the harvest stage. Each stage has its own bands and reminders, and the history stays in the log. A vertical tower measures every 36 h and tops off every 24 h; the pump reminder shows only if the transplant said there is a pump.
158 - **Stages.** A station starts as a rockwool seedling; logging a **transplant** (system, litres, sites, pump) moves it to the hydroponic stage and the first harvest after that to the harvest stage. Each stage has its own bands and reminders, and the history stays in the log. Reminders measure every 24 h (a vertical tower every 12 h) and a tower tops off every 24 h; the pump reminder shows only if the transplant said there is a pump.
159159 - **Reminders** fall due a set time after the last log entry that counts for them (a top-off resets "top off", a watering resets the moisture check), or after the stage began. Done, Snooze and a different cadence (`POST /care/reminder {id, action: "done"|"snooze"|"every", hours}`) are the owner's.
160160 - **Profiles are files.** `src/care-profiles/spinach.json` ships with the app; put another plant (lettuce, basil) as `FORGE_APP_DATA/so101/care-profiles/<name>.json` in the same shape (stages, `bands`, `reminders` with `everyHours`, optional `tower`, `needs`, `resets`; see src/care-profile.ts, which names what is wrong with a bad file and skips it). `CARE_PROFILE=<id>` picks which one the app runs (default `spinach`).
161 - **Sources.** Every target, reminder and stage cites a reference. A value shows a footnote marker; tapping it opens a card with the reference, what it supports and a link. A value with no source says "grower consensus (unsourced)". `GET /care/learn` is the Learn page: the references by topic, extension and peer-reviewed first, then a Fundamentals reading path (week one, then week two) and a list of items named but not checked. `GET /care/learn.json` and the terminal's `/care learn` give the same with URLs. In a profile, `references` is a list of `{id, title, publisher, url, kind, accessed, topics, covers}`: `kind` is `extension`, `peer-reviewed`, `grower` or `vendor`, `url` is `https`, and ids are unique. `sources` (a list of `{id, claim}`, each id naming a reference) sits on each band (under the stage's `sources`, keyed by measure) and on each reminder, with an optional `why` on a reminder; `light.sources`, `light.warnSources` and `timeline.sources` do the same for the light hours, the bolting warning and the timeline. A source naming no reference makes the profile load fail. `/care/api` and `/care` list each reminder's `why` and source ids.
161162 - **The log book** is `FORGE_APP_DATA/so101/care.json`. Entries are `measurement` (ph, ec, waterTemp, airTemp, level, lightHours), `action` (sow, water, topoff, change, nutrients, thin, harvest, airstone, pumpcheck, transplant), `observation` and `problem`, each with a time (now unless given). A measurement is a `Reading {kind, value, at, source: manual|sensor}`, so a sensor can write the same later. Photos on observations and the pump schedule are not part of it yet.
162163 - **Live.** `GET /care/events` is a server-sent stream that says `care` after every change to the book, from any door; the page listens with a few lines of script and refreshes itself (not while a field has focus). Without the script the page works the same, on reload. A refused form returns to `/care?error=…` with the message in an alert line.
163164 - **Three doors.** The page; JSON: `GET /care/api` (anyone), `POST /care/log`, `PUT` or `DELETE /care/log/<id>` and `POST /care/reminder` (owner, same-origin only, 8 KB at most); and in the terminal `/care` (stage, health, what is due, the last entries with their ids) for anyone, with `/care log ph 6.1`, `/care did topoff`, `/care did harvest 40`, `/care note <text>`, `/care problem algae [detail]`, `/care done <reminder>`, `/care snooze <reminder> [hours]` and `/care delete <id>` for the owner. All three act on the same log, so a write in one shows in the others at once.
164165
165166 ### pH and EC probes (on /care)
166167
167 An ADS1115 breakout on the Pi's I²C bus (address 0x48; `sudo apt install i2c-tools`, I²C enabled) reads the pH probe board on input A0 and the EC probe board on A1. The arm server serves `GET /probes` and `POST /probes/calibrate`; the care page shows the readings and walks the owner through the calibration: pH 7.0 buffer, then pH 4.0, then the 1413 µS/cm solution, each captured only once the probe has settled. A probe gives no value until it is calibrated. Calibration lives in `probes.json` under the data dir. Values are compensated for the water temperature (the latest `waterTemp` in the log book from the last two hours, else 25 °C), and every `probeEveryMs` (5 min on the forge) a calibrated reading is written into the log book as a sensor reading. Finishing a calibration marks the monthly "calibrate" reminder done.
168 An ADS1115 breakout on the Pi's I²C bus (address 0x48; `sudo apt install i2c-tools`, I²C enabled) reads the pH probe board on input A0 and the EC probe board on A1. The arm server serves `GET /probes` and `POST /probes/calibrate`; the care page shows the readings and walks the owner through the calibration: pH 7.0 buffer, then pH 4.0, then the 1413 µS/cm solution, each captured only once the probe has settled. A probe gives no value until it is calibrated. Calibration lives in `probes.json` under the data dir. Values are compensated for the water temperature (the latest `waterTemp` in the log book from the last two hours, else 25 °C), and every `probeEveryMs` (5 min on the forge) a calibrated reading is written into the log book as a sensor reading. Finishing a calibration marks the weekly "calibrate" reminder done.
168169
169170 The page paints from the last answer (a few seconds old, shown with its age) and refreshes it in the background, so a Pi that is down or slow never delays `/care`. The terminal has the same two doors: `/probes` prints the readings, calibration dates and the next step (anyone), and `/probes calibrate <ph7|ph4|ec1413> [solution tempC]` captures a step (owner). Both go through the same code as the page's buttons, and a calibration made in either shows live for everyone watching `/care`.
170171
Software/so101/src/care-fundamentals.ts added +54
@@ -0,0 +1,54 @@
1 /** The Learn page's Fundamentals path: biology from first principles, in reading order, week one first. Every link was fetched by the steward's researcher on 2026-10-27. */
2
3 import { esc } from "./story-markdown.ts";
4
5 export interface Reading {
6 label: string;
7 url: string;
8 }
9 export interface Step {
10 week: 1 | 2;
11 title: string;
12 kind: string;
13 minutes: number;
14 links: Reading[];
15 }
16
17 const OS = "https://openstax.org/books/biology-2e/pages/";
18 export const PATH: readonly Step[] = [
19 { week: 1, title: "Photosynthesis (OpenStax Biology 2e, 8.1 and 8.3)", kind: "textbook", minutes: 60, links: [{ label: "8.1 Overview of photosynthesis", url: `${OS}8-1-overview-of-photosynthesis` }, { label: "8.3 Using light energy to make organic molecules", url: `${OS}8-3-using-light-energy-to-make-organic-molecules` }] },
20 { week: 1, title: "Plant nutrition (OpenStax 31.1)", kind: "textbook", minutes: 30, links: [{ label: "31.1 Nutritional requirements of plants", url: `${OS}31-1-nutritional-requirements-of-plants` }] },
21 { week: 1, title: "Bugbee (Utah State), Turning photons into food", kind: "talk", minutes: 50, links: [{ label: "Slides", url: "https://www.energy.gov/sites/prod/files/2017/02/f34/bugbee_agriculture_longbeach2017_0.pdf" }, { label: "32-minute video (UGA HortLAMP)", url: "https://hortlamp.uga.edu/outreach/outreach-instructional-videos" }] },
22 { week: 1, title: "Spinach bolting", kind: "peer-reviewed and extension", minutes: 20, links: [{ label: "Chun et al., Acta Hort. 515 (abstract)", url: "https://ishs.org/ishs-article/515_25/" }, { label: "UAF extension", url: "https://itgrowsinalaska.community.uaf.edu/?p=1246" }] },
23 { week: 1, title: "Cornell CEA Hydroponic Lettuce Handbook (nutrients, pH, light, germination; skim)", kind: "extension", minutes: 45, links: [{ label: "Handbook (PDF)", url: "https://cpb-us-e1.wpmucdn.com/blogs.cornell.edu/dist/8/8824/files/2019/06/Cornell-CEA-Lettuce-Handbook-.pdf" }] },
24 { week: 1, title: "Sutton et al. 2006, Pythium root rot in hydroponic crops (oxygen, temperature; skim)", kind: "peer-reviewed", minutes: 20, links: [{ label: "Summa Phytopathologica", url: "https://www.scielo.br/j/sp/a/cGCh3FGRRzGLVFJf577mypy/?lang=en" }] },
25 { week: 1, title: "Hort Americas on VPD and calcium", kind: "vendor", minutes: 20, links: [{ label: "Vapor pressure deficit", url: "https://hortamericas.com/blog/science/understanding-vapor-pressure-deficit-vpd" }, { label: "Calcium deficiency", url: "https://hortamericas.com/blog/science/how-to-avoid-calcium-deficiency-in-controlled-environment-food-crops/" }] },
26 { week: 2, title: "Water transport (OpenStax 30.5)", kind: "textbook", minutes: 0, links: [{ label: "30.5 Transport of water and solutes in plants", url: `${OS}30-5-transport-of-water-and-solutes-in-plants` }] },
27 { week: 2, title: "Light signals and photoperiod (OpenStax 30.6)", kind: "textbook", minutes: 0, links: [{ label: "30.6 Plant sensory systems and responses", url: `${OS}30-6-plant-sensory-systems-and-responses` }] },
28 { week: 2, title: "Missouri/SARE nutrient solutions", kind: "extension", minutes: 0, links: [{ label: "Class 2 deck (PDF)", url: "https://projects.sare.org/media/pdf/N/u/t/Nutrient-solutions-Class-2.pdf" }] },
29 { week: 2, title: "UF/IFAS HS1279, leafy greens", kind: "extension", minutes: 0, links: [{ label: "HS1279 (PDF)", url: "https://edis.ifas.ufl.edu/publication/HS1279/pdf" }] },
30 { week: 2, title: "UGA Circular 1304, greenhouse spinach", kind: "extension", minutes: 0, links: [{ label: "C1304", url: "https://fieldreport.caes.uga.edu/publications/C1304/" }] },
31 { week: 2, title: "Bugbee, Nutrient management in recirculating hydroponics", kind: "peer-reviewed", minutes: 0, links: [{ label: "Acta Hort. 648", url: "https://ishs.org/ishs-article/648_12/" }] },
32 ];
33
34 /** Named but not fetched, so never linked as verified. */
35 export const UNCHECKED: readonly string[] = [
36 "Michigan State (Runkle) pages on DLI and VPD",
37 "Resh, Hydroponic Food Production (book)",
38 "Taiz & Zeiger, Plant Physiology and Development (book)",
39 ];
40
41 const hours = (m: number): string => `${Math.round((m / 60) * 10) / 10} h`;
42
43 export function renderFundamentals(): string {
44 const weeks = ([1, 2] as const).map((w) => {
45 const steps = PATH.filter((s) => s.week === w);
46 const total = steps.reduce((n, s) => n + s.minutes, 0);
47 const head = w === 1 ? `Week one (about ${hours(total)})` : "Week two";
48 const items = steps
49 .map((s) => `<li data-step="${esc(s.kind)}"><span><strong>${esc(s.title)}</strong><br><span class="dim">${esc(s.kind)}${s.minutes ? ` · about ${s.minutes} min` : ""}</span><br>${s.links.map((l) => `<a href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.label)} (new tab)</a>`).join(" · ")}</span></li>`)
50 .join("");
51 return `<h3>${head}</h3><ol class="list">${items}</ol>`;
52 });
53 return `<section class="panel" id="fundamentals" aria-labelledby="t-fundamentals"><h2 id="t-fundamentals" style="margin-top:0">Fundamentals: the biology from first principles</h2>${weeks.join("")}<h3>Optional, not checked</h3><ul class="list">${UNCHECKED.map((u) => `<li>${esc(u)}</li>`).join("")}</ul></section>`;
54 }
Software/so101/src/care-page.ts +9 −7
@@ -8,6 +8,7 @@ import type { DosingView } from "./dose-pumps.ts";
88 import { renderPumps } from "./pumps-card.ts";
99 import { MODES } from "./doser.ts";
1010 import { renderSignedIn, SIGNED_IN_STYLE, type SignedIn } from "./signed-in.ts";
11 import { CITE_STYLE, cites } from "./care-sources.ts";
1112 import { esc } from "./story-markdown.ts";
1213
1314 const DAY = 86_400_000;
@@ -48,11 +49,11 @@ a { color: var(--link); } a:focus-visible, button:focus-visible { outline: 2px s
4849 `;
4950
5051 /** One chart: the readings as a line, the target band as a shaded strip, the last 30 days. */
51 function chart(kind: Measure, snap: Snapshot, band: Band | undefined, now: number): string {
52 function chart(kind: Measure, snap: Snapshot, band: Band | undefined, now: number, cite: string): string {
5253 const { name, unit } = LABELS[kind];
5354 const pts = readings(snap.entries, kind).filter((r) => r.at > now - 30 * DAY && r.at <= now);
5455 const W = 320, H = 120, L = 30, R = 8, T = 8, B = 16;
55 if (!pts.length) return `<div class="panel chart"><h3>${esc(name)}</h3><p class="dim">No readings yet${band ? `; typical ${band[0]}–${band[1]}${esc(unit)}` : ""}.</p></div>`;
56 if (!pts.length) return `<div class="panel chart"><h3>${esc(name)}</h3><p class="dim">No readings yet${band ? `; typical ${band[0]}–${band[1]}${esc(unit)} ${cite}` : ""}.</p></div>`;
5657 const values = pts.map((p) => p.value).concat(band ?? []);
5758 let lo = Math.min(...values), hi = Math.max(...values);
5859 const pad = (hi - lo || 1) * 0.15;
@@ -63,7 +64,7 @@ function chart(kind: Measure, snap: Snapshot, band: Band | undefined, now: numbe
6364 const strip = band ? `<rect x="${L}" y="${y(band[1]).toFixed(1)}" width="${W - L - R}" height="${(y(band[0]) - y(band[1])).toFixed(1)}" fill="var(--band)"/>` : "";
6465 const last = pts[pts.length - 1]!;
6566 const dots = pts.map((p) => `<circle cx="${x(p.at).toFixed(1)}" cy="${y(p.value).toFixed(1)}" r="2.5" fill="var(--accent)"/>`).join("");
66 return `<div class="panel chart"><h3>${esc(name)}: ${last.value}${esc(unit)} <span class="dim">${ago(now - last.at)}${band ? `, typical ${band[0]}–${band[1]}` : ""}</span></h3>
67 return `<div class="panel chart"><h3>${esc(name)}: ${last.value}${esc(unit)} <span class="dim">${ago(now - last.at)}${band ? `, typical ${band[0]}–${band[1]} ${cite}` : ""}</span></h3>
6768 <svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(name)} over the last 30 days${band ? `, typical band ${band[0]} to ${band[1]}` : ""}">${strip}
6869 <text x="2" y="${T + 8}" font-size="9" fill="var(--dim)">${hi.toFixed(1)}</text><text x="2" y="${H - B}" font-size="9" fill="var(--dim)">${lo.toFixed(1)}</text>
6970 <polyline points="${line}" fill="none" stroke="var(--accent)" stroke-width="1.5"/>${dots}</svg></div>`;
@@ -139,17 +140,18 @@ ${dosing.simulated ? `<p class="dim">No pumps are wired yet: a dose is only note
139140 .join("")}</ul>${pumps !== undefined ? renderPumps(pumps) : ""}</section>`
140141 : "";
141142
143 const cite = cites(profile);
142144 const dueRows = due
143145 .map((d) => {
144146 const late = d.overdueMs > 0 && !d.snoozedUntil;
145147 const state = d.snoozedUntil ? `snoozed until ${when(d.snoozedUntil)}` : late ? `overdue ${Math.max(1, Math.floor(d.overdueMs / 3_600_000))} h` : `due ${when(d.dueAt)}`;
146148 const acts = owner ? `<span>${post("/care/reminder", { id: d.reminder.id, action: "done" }, "Done")} ${post("/care/reminder", { id: d.reminder.id, action: "snooze", hours: "24" }, "Snooze 1 d", "quiet")}</span>` : "";
147 return `<li data-reminder="${esc(d.reminder.id)}"><span>${esc(d.reminder.label)}<br><span class="${late ? "late" : "dim"}">${esc(state)} · every ${d.everyHours} h</span></span>${acts}</li>`;
149 return `<li data-reminder="${esc(d.reminder.id)}"><span>${esc(d.reminder.label)}<br>${d.reminder.why ? `<span class="dim">${esc(d.reminder.why)}</span> ` : ""}${cite(d.reminder.sources)}<br><span class="${late ? "late" : "dim"}">${esc(state)} · every ${d.everyHours} h</span></span>${acts}</li>`;
148150 })
149151 .join("");
150152
151153 const kinds: Measure[] = ["ph", "ec", "waterTemp", "airTemp", "lightHours"];
152 const charts = kinds.map((k) => chart(k, snap, cur.stage.bands[k], now)).join("");
154 const charts = kinds.map((k) => chart(k, snap, cur.stage.bands[k], now, cite(cur.stage.sources[k]))).join("");
153155
154156 let lastStage = "";
155157 const rows = [...snap.entries]
@@ -170,7 +172,7 @@ ${dosing.simulated ? `<p class="dim">No pumps are wired yet: a dose is only note
170172 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
171173 <title>Grow care · ${esc(profile.name)}</title>
172174 <meta name="description" content="Plant care for the grow box: reminders, a log book and charts against typical ranges.">
173 <style>${STYLE}${SIGNED_IN_STYLE}</style>
175 <style>${STYLE}${CITE_STYLE}${SIGNED_IN_STYLE}</style>
174176 </head>
175177 <body>
176178 <a class="skip" href="#main">Skip to the care page</a>
@@ -185,7 +187,7 @@ ${probes}
185187 ${dosingCard}
186188 <h2>Reminders</h2><div class="panel"><ul class="list">${dueRows}</ul></div>
187189 <h2>Metrics</h2>
188 <p class="dim">Typical ranges, not guaranteed. ${esc(profile.note)} Days since the last water change: ${sum.daysSinceChange ?? "none logged"}. Harvest so far: ${sum.harvestGrams} g. Light target ${profile.light.hours} h, warn above ${profile.light.warnAbove} h (bolting).</p>
190 <p class="dim">Typical ranges, not guaranteed. ${esc(profile.note)} Days since the last water change: ${sum.daysSinceChange ?? "none logged"}. Harvest so far: ${sum.harvestGrams} g. Light target ${profile.light.hours} h, DLI ${profile.light.dli[0]}–${profile.light.dli[1]} ${cite(profile.light.sources)}; warn above ${profile.light.warnAbove} h (bolting) ${cite(profile.light.warnSources)}. Timeline ${cite(profile.timeline.sources)}. <a href="/care/learn">Learn more</a>.</p>
189191 <div class="charts">${charts}</div>
190192 <h2>Log book</h2><div class="panel"><ul class="list">${rows || '<li class="dim">Nothing logged yet.</li>'}</ul></div>
191193 </main>
Software/so101/src/care-profile.ts +62 −3
@@ -11,9 +11,32 @@ export type Measure = (typeof MEASURES)[number];
1111
1212 export type Band = readonly [number, number];
1313
14 /** A reference a value leans on, and the exact claim it supports. */
15 export interface Source {
16 id: string;
17 claim: string;
18 }
19 export const KINDS = ["extension", "peer-reviewed", "grower", "vendor"] as const;
20 export interface Reference {
21 id: string;
22 title: string;
23 publisher: string;
24 url: string;
25 kind: (typeof KINDS)[number];
26 accessed: string;
27 /** Learn-page groups: light, nutrients, climate, timeline, problems. */
28 topics: string[];
29 /** One line on what the source covers. */
30 covers: string;
31 }
32
1433 export interface Reminder {
1534 id: string;
1635 label: string;
36 /** Why it is asked, in a line; shown under the label with its source. */
37 why?: string;
38 /** No sources: shown as grower consensus (unsourced). */
39 sources: Source[];
1740 everyHours: number;
1841 /** Replaces everyHours in a vertical tower, whose small reservoir drifts faster. */
1942 tower?: number;
@@ -27,6 +50,8 @@ export interface Stage {
2750 id: string;
2851 name: string;
2952 bands: Partial<Record<Measure, Band>>;
53 /** Sources per band; a band with none is shown as unsourced. */
54 sources: Partial<Record<Measure, Source[]>>;
3055 reminders: Reminder[];
3156 transplantCheck?: { afterDays: Band; label: string };
3257 /** Seeds in this stage sprout within these days of sowing; until the last of them the stage goes by `label` ("Germinating"). */
@@ -37,7 +62,9 @@ export interface Profile {
3762 id: string;
3863 name: string;
3964 note: string;
40 light: { hours: number; warnAbove: number; dli: Band };
65 light: { hours: number; warnAbove: number; dli: Band; sources: Source[]; warnSources: Source[] };
66 timeline: { sources: Source[] };
67 references: Reference[];
4168 stages: Stage[];
4269 }
4370
@@ -66,11 +93,34 @@ const obj = (v: unknown, what: string): Record<string, unknown> => {
6693 return v as Record<string, unknown>;
6794 };
6895
96 const sourcesOf = (v: unknown, what: string, refs: ReadonlySet<string>): Source[] => {
97 if (v === undefined) return [];
98 if (!Array.isArray(v) || v.length > 8) throw new ProfileError(`${what}: sources are a list`);
99 return v.map((x) => {
100 const o = obj(x, `${what}: a source`);
outdated

No test checks this refusal, or the duplicate-id refusal above it. Add a test that feeds parseProfile a source id with no matching reference and expects /no reference/, and another with two references sharing an id.

101 const rid = id(o.id, `${what}: a source`);
102 if (!refs.has(rid)) throw new ProfileError(`${what}: no reference "${rid}"`);
103 return { id: rid, claim: words(o.claim, `${what}: a source's claim`, 300) };
104 });
105 };
106
107 const reference = (raw: unknown): Reference => {
108 const r = obj(raw, "a reference");
109 const url = words(r.url, "a reference's url", 300);
110 if (!/^https:\/\/[^\s"<>]+$/.test(url)) throw new ProfileError("a reference's url is https");
111 if (!KINDS.includes(r.kind as never)) throw new ProfileError(`a reference's kind is ${KINDS.join(", ")}`);
112 const topics = Array.isArray(r.topics) ? r.topics.map((t) => words(t, "a topic", 20)) : [];
113 return { id: id(r.id, "a reference"), title: words(r.title, "a reference's title"), publisher: words(r.publisher, "a reference's publisher"), url, kind: r.kind as Reference["kind"], accessed: words(r.accessed, "accessed", 10), topics, covers: words(r.covers, "covers", 300) };
114 };
115
69116 /** Any JSON → a profile; throws a ProfileError saying what is wrong. */
70117 export function parseProfile(input: unknown): Profile {
71118 const p = obj(input, "a profile");
72119 const light = obj(p.light, "light");
73120 if (!Array.isArray(p.stages) || !p.stages.length || p.stages.length > 6) throw new ProfileError("a profile has 1 to 6 stages");
121 const references = Array.isArray(p.references) ? p.references.map(reference) : [];
122 const refIds = new Set(references.map((r) => r.id));
123 if (refIds.size !== references.length) throw new ProfileError("two references share an id");
74124 const stages = p.stages.map((raw): Stage => {
75125 const s = obj(raw, "a stage");
76126 const bandsIn = obj(s.bands ?? {}, "bands");
@@ -79,6 +129,12 @@ export function parseProfile(input: unknown): Profile {
79129 if (!MEASURES.includes(k as Measure)) throw new ProfileError(`no such measure "${k}"`);
80130 bands[k as Measure] = band(v, `${k} band`);
81131 }
132 const srcIn = obj(s.sources ?? {}, "sources");
133 const sources: Partial<Record<Measure, Source[]>> = {};
134 for (const [k, v] of Object.entries(srcIn)) {
135 if (!(k in bands)) throw new ProfileError(`sources for "${k}", which has no band`);
136 sources[k as Measure] = sourcesOf(v, `${k} sources`, refIds);
137 }
82138 if (!Array.isArray(s.reminders) || s.reminders.length > 20) throw new ProfileError("a stage has up to 20 reminders");
83139 const seen = new Set<string>();
84140 const reminders = s.reminders.map((rr): Reminder => {
@@ -94,7 +150,7 @@ export function parseProfile(input: unknown): Profile {
94150 if (r.needs !== undefined && r.needs !== "pump" && r.needs !== "tower") throw new ProfileError('needs is "pump" or "tower"');
95151 const resets = r.resets ?? [];
96152 if (!Array.isArray(resets) || !resets.every((k) => typeof k === "string" && /^[a-zA-Z]+:[a-zA-Z-]+$/.test(k))) throw new ProfileError("resets are keys like action:topoff");
97 return { id: rid, label: words(r.label, "a reminder's label"), everyHours: hours(r.everyHours), ...(r.tower !== undefined ? { tower: hours(r.tower) } : {}), ...(r.needs ? { needs: r.needs } : {}), resets: resets as string[] };
153 return { id: rid, label: words(r.label, "a reminder's label"), ...(r.why !== undefined ? { why: words(r.why, "a reminder's why") } : {}), sources: sourcesOf(r.sources, `reminder ${rid}`, refIds), everyHours: hours(r.everyHours), ...(r.tower !== undefined ? { tower: hours(r.tower) } : {}), ...(r.needs ? { needs: r.needs } : {}), resets: resets as string[] };
98154 });
99155 const tc = s.transplantCheck === undefined ? undefined : obj(s.transplantCheck, "transplantCheck");
100156 const sp = s.sprout === undefined ? undefined : obj(s.sprout, "sprout");
@@ -102,6 +158,7 @@ export function parseProfile(input: unknown): Profile {
102158 id: id(s.id, "a stage"),
103159 name: words(s.name, "a stage's name", 60),
104160 bands,
161 sources,
105162 reminders,
106163 ...(tc ? { transplantCheck: { afterDays: band(tc.afterDays, "afterDays"), label: words(tc.label, "the check's label") } } : {}),
107164 ...(sp ? { sprout: { days: band(sp.days, "sprout days"), label: words(sp.label, "the sprout label", 40) } } : {}),
@@ -112,7 +169,9 @@ export function parseProfile(input: unknown): Profile {
112169 id: id(p.id, "a profile"),
113170 name: words(p.name, "a profile's name", 60),
114171 note: words(p.note ?? "Typical ranges, not guaranteed.", "the note", 400),
115 light: { hours: num(light.hours, "light hours"), warnAbove: num(light.warnAbove, "light warnAbove"), dli: band(light.dli, "dli") },
172 light: { hours: num(light.hours, "light hours"), warnAbove: num(light.warnAbove, "light warnAbove"), dli: band(light.dli, "dli"), sources: sourcesOf(light.sources, "light", refIds), warnSources: sourcesOf(light.warnSources, "light warning", refIds) },
173 timeline: { sources: sourcesOf(obj(p.timeline ?? {}, "timeline").sources, "timeline", refIds) },
174 references,
116175 stages,
117176 };
118177 }
Software/so101/src/care-profiles/spinach.json +725 −51
@@ -1,54 +1,728 @@
11 {
2 "id": "spinach",
3 "name": "Spinach",
4 "note": "Typical ranges, not guaranteed: Cornell CEA spinach report, Organic Seed Alliance, Hydrobuilder; the rockwool stage and the timeline are grower consensus.",
5 "light": { "hours": 12, "warnAbove": 14, "dli": [12, 17] },
6 "timeline": { "germinateDays": 3, "respaceDay": 9, "babyLeafDays": [14, 16], "fullLeafDays": [30, 45] },
7 "stages": [
8 {
9 "id": "seedling",
10 "name": "Rockwool seedling",
11 "bands": { "ph": [5.5, 6.0], "ec": [0.5, 1.4], "airTemp": [18, 24], "lightHours": [10, 14] },
12 "reminders": [
13 { "id": "moisture", "label": "Check cube moisture (a light cube needs bottom water); vent or remove the dome", "everyHours": 24, "resets": ["action:water"] },
14 { "id": "light", "label": "Light ran", "everyHours": 24 },
15 { "id": "bottom-water", "label": "Bottom-water or refill the tray at this stage's EC and pH, then log it", "everyHours": 60, "resets": ["action:water", "action:topoff"] },
16 { "id": "thin", "label": "Thin to the strongest seedling per cube once true leaves show", "everyHours": 168, "resets": ["action:thin"] }
17 ],
18 "transplantCheck": { "afterDays": [14, 21], "label": "Roots out of the cube bottom and 2-4 true leaves?" },
19 "sprout": { "days": [3, 7], "label": "Germinating" }
20 },
21 {
22 "id": "hydroponic",
23 "name": "Hydroponic",
24 "bands": { "ph": [5.5, 6.5], "ec": [1.6, 2.0], "waterTemp": [18, 20], "airTemp": [20, 24], "lightHours": [10, 14] },
25 "reminders": [
26 { "id": "light", "label": "Light ran", "everyHours": 24 },
27 { "id": "level", "label": "Water level; look for wilting", "everyHours": 24, "resets": ["measurement:level", "action:topoff"] },
28 { "id": "pump", "label": "Pump ran? Top emitter clear, every level wet", "everyHours": 24, "needs": "pump", "resets": ["action:pumpcheck"] },
29 { "id": "measure", "label": "Measure pH, EC and water temperature", "everyHours": 60, "tower": 36, "resets": ["measurement:ph", "measurement:ec", "measurement:waterTemp"] },
30 { "id": "topoff", "label": "Top off with plain water", "everyHours": 60, "tower": 24, "resets": ["action:topoff", "action:change"] },
31 { "id": "calibrate", "label": "Calibrate the pH and EC probes (4.0 and 7.0 buffers, 1413 µS/cm)", "everyHours": 720 },
32 { "id": "air-stone", "label": "Clean the air stone", "everyHours": 168, "resets": ["action:airstone"] },
33 { "id": "roots", "label": "Check the roots (brown or slimy = Pythium); thin or harvest", "everyHours": 168 },
34 { "id": "tower", "label": "Tower: top emitter not clogged, every level wet, no algae on lit parts", "everyHours": 168, "needs": "tower" },
35 { "id": "change", "label": "Full reservoir change and wipe down", "everyHours": 336, "resets": ["action:change"] }
36 ]
37 },
38 {
39 "id": "harvest",
40 "name": "Harvest",
41 "bands": { "ph": [5.5, 6.5], "ec": [1.8, 2.3], "waterTemp": [18, 20], "airTemp": [20, 24], "lightHours": [10, 14] },
42 "reminders": [
43 { "id": "light", "label": "Light ran", "everyHours": 24 },
44 { "id": "level", "label": "Water level; look for wilting", "everyHours": 24, "resets": ["measurement:level", "action:topoff"] },
45 { "id": "pump", "label": "Pump ran?", "everyHours": 24, "needs": "pump", "resets": ["action:pumpcheck"] },
46 { "id": "measure", "label": "Measure pH, EC and water temperature", "everyHours": 60, "tower": 36, "resets": ["measurement:ph", "measurement:ec", "measurement:waterTemp"] },
47 { "id": "bolting", "label": "Stalk forming? Look at the side camera (watch when air has been above 24 C)", "everyHours": 48 },
48 { "id": "calibrate", "label": "Calibrate the pH and EC probes (4.0 and 7.0 buffers, 1413 µS/cm)", "everyHours": 720 },
49 { "id": "harvest", "label": "Cut and come again: harvest outer leaves", "everyHours": 168, "resets": ["action:harvest"] },
50 { "id": "change", "label": "Full reservoir change and wipe down", "everyHours": 336, "resets": ["action:change"] }
51 ]
52 }
2 "id": "spinach",
3 "name": "Spinach",
4 "note": "Typical ranges, not guaranteed. Each value links to its source; anything without one is grower consensus.",
5 "light": {
6 "hours": 12,
7 "warnAbove": 14,
8 "dli": [
9 12,
10 17
11 ],
12 "sources": [
13 {
14 "id": "cornell-cea-spinach",
15 "claim": "DLI 17 mol/m²/d for baby leaf"
16 },
17 {
18 "id": "mdpi-agronomy-2020",
19 "claim": "DLI 11.5–20.2 tested for spinach"
20 }
21 ],
22 "warnSources": [
23 {
24 "id": "osa-spinach-manual",
25 "claim": "Long-day plant: older lines bolt at 14 h or more, modern lines at about 16 h"
26 },
27 {
28 "id": "uaf-spinach",
29 "claim": "Photoperiod drives the bolting response"
30 }
5331 ]
32 },
33 "timeline": {
34 "germinateDays": 3,
35 "respaceDay": 9,
36 "babyLeafDays": [
37 14,
38 16
39 ],
40 "fullLeafDays": [
41 30,
42 45
43 ],
44 "sources": [
45 {
46 "id": "cornell-gpn-2016",
47 "claim": "Germination about 3 days at 25 °C or less"
48 },
49 {
50 "id": "cornell-cea-spinach",
51 "claim": "Respace around day 9; a second cutting pays; baby leaf at 1500 plants/m²"
52 },
53 {
54 "id": "uga-c1304",
55 "claim": "Spinach transplants 2–3 weeks after sowing"
56 },
57 {
58 "id": "cornell-gpn-2016",
59 "claim": "Lettuce data: transplant at 3–4 true leaves, about day 11 in high light"
60 },
61 {
62 "id": "uky-hydro-lettuce",
63 "claim": "Lettuce data: seedlings 2–3 weeks"
64 }
65 ]
66 },
67 "stages": [
68 {
69 "id": "seedling",
70 "name": "Rockwool seedling",
71 "bands": {
72 "ph": [
73 5.5,
74 6
75 ],
76 "ec": [
77 0.5,
78 1.4
79 ],
80 "airTemp": [
81 18,
82 24
83 ],
84 "lightHours": [
85 10,
86 14
87 ]
88 },
89 "reminders": [
90 {
91 "id": "moisture",
92 "label": "Check cube moisture (a light cube needs bottom water); vent or remove the dome",
93 "everyHours": 24,
94 "resets": [
95 "action:water"
96 ],
97 "sources": [
98 {
99 "id": "cornell-gpn-2016",
100 "claim": "Lettuce data: seeds surface-sown in rockwool and sub-irrigated; overhead watering may retard germination"
101 }
102 ]
103 },
104 {
105 "id": "room-temp",
106 "label": "Keep germinating cubes at room temperature; no heat mat",
107 "everyHours": 24,
108 "resets": [],
109 "sources": [
110 {
111 "id": "kaska-2024",
112 "claim": "Best germination at 20 °C, falling above 20 °C"
113 },
114 {
115 "id": "vonrosen-2022",
116 "claim": "Best germination at 12 °C, inhibited from about 21 °C"
117 },
118 {
119 "id": "uga-c1304",
120 "claim": "Germination optimum 18–21 °C"
121 }
122 ],
123 "why": "The spinach germination data peak at 12–21 °C and decline above about 20 °C; no source recommends a heat mat for spinach"
124 },
125 {
126 "id": "light",
127 "label": "Light ran (sprouts need light right after sowing, or they stretch)",
128 "everyHours": 24,
129 "sources": [
130 {
131 "id": "hydrobuilder-spinach",
132 "claim": "10–14 h of light"
133 },
134 {
135 "id": "cornell-gpn-2016",
136 "claim": "Lettuce data: at least 50 µmol/m²/s from sowing, DLI at least 4 during germination"
137 }
138 ],
139 "why": "Hours of light a day set the daily light integral"
140 },
141 {
142 "id": "bottom-water",
143 "label": "Bottom-water or refill the tray with feed solution (seedlings are fed from day 0), then log it",
144 "everyHours": 60,
145 "resets": [
146 "action:water",
147 "action:topoff"
148 ],
149 "sources": [
150 {
151 "id": "cornell-gpn-2016",
152 "claim": "Lettuce data: seedlings fed from day 0 at 100–150 ppm N"
153 }
154 ]
155 },
156 {
157 "id": "thin",
158 "label": "Thin to the strongest seedling per cube once true leaves show",
159 "everyHours": 168,
160 "resets": [
161 "action:thin"
162 ],
163 "sources": []
164 }
165 ],
166 "transplantCheck": {
167 "afterDays": [
168 14,
169 21
170 ],
171 "label": "Roots out of the cube bottom and 3–4 true leaves?"
172 },
173 "sprout": {
174 "days": [
175 3,
176 7
177 ],
178 "label": "Germinating"
179 },
180 "sources": {
181 "lightHours": [
182 {
183 "id": "hydrobuilder-spinach",
184 "claim": "10–14 h of light"
185 },
186 {
187 "id": "cornell-gpn-2016",
188 "claim": "Lettuce data: at least 50 µmol/m²/s from sowing, or sprouts stretch (seedling DLI 22)"
189 }
190 ],
191 "airTemp": [
192 {
193 "id": "uga-c1304",
194 "claim": "Spinach germination optimum 18–21 °C, growth 15–25 °C"
195 },
196 {
197 "id": "cornell-gpn-2016",
198 "claim": "Lettuce data: 24 °C day / 19 °C night, RH 50–70%"
199 }
200 ],
201 "ph": [
202 {
203 "id": "uky-hydro-lettuce",
204 "claim": "Lettuce data: hydrate and stabilize rockwool at pH 5.5–5.8 before seeding"
205 }
206 ]
207 }
208 },
209 {
210 "id": "hydroponic",
211 "name": "Hydroponic",
212 "bands": {
213 "ph": [
214 5.5,
215 6.5
216 ],
217 "ec": [
218 1.6,
219 2
220 ],
221 "waterTemp": [
222 18,
223 20
224 ],
225 "airTemp": [
226 20,
227 24
228 ],
229 "lightHours": [
230 10,
231 14
232 ]
233 },
234 "reminders": [
235 {
236 "id": "light",
237 "label": "Light ran",
238 "everyHours": 24,
239 "sources": [
240 {
241 "id": "hydrobuilder-spinach",
242 "claim": "10–14 h of light"
243 }
244 ],
245 "why": "Hours of light a day set the daily light integral"
246 },
247 {
248 "id": "level",
249 "label": "Water level; look for wilting",
250 "everyHours": 24,
251 "resets": [
252 "measurement:level",
253 "action:topoff"
254 ],
255 "sources": [],
256 "why": "A drifting level means plants drink faster than the tank refills"
257 },
258 {
259 "id": "pump",
260 "label": "Pump running continuously? Top emitter clear, every level wet",
261 "everyHours": 24,
262 "needs": "pump",
263 "resets": [
264 "action:pumpcheck"
265 ],
266 "sources": [
267 {
268 "id": "uky-hydro-lettuce",
269 "claim": "Lettuce data: NFT keeps a constant film, about 1–2 L/min per channel on a 1–3% slope"
270 }
271 ],
272 "why": "Extension sources describe continuous flow; an on/off cycle (such as 15 on, 15 off) is unsourced"
273 },
274 {
275 "id": "measure",
276 "label": "Measure pH, EC and water temperature",
277 "everyHours": 24,
278 "resets": [
279 "measurement:ph",
280 "measurement:ec",
281 "measurement:waterTemp"
282 ],
283 "sources": [
284 {
285 "id": "osu-root-zone",
286 "claim": "Measure pH, EC and dissolved oxygen daily"
287 }
288 ],
289 "why": "pH and EC drift",
290 "tower": 12
291 },
292 {
293 "id": "topoff",
294 "label": "Top off with plain water",
295 "everyHours": 60,
296 "tower": 24,
297 "resets": [
298 "action:topoff",
299 "action:change"
300 ],
301 "sources": [],
302 "why": "Small reservoirs drift (cadence is grower consensus; Ohio State: measure daily, discard when problems arise)"
303 },
304 {
305 "id": "calibrate",
306 "label": "Calibrate the pH and EC probes (4.0 and 7.0 buffers, 1413 µS/cm)",
307 "everyHours": 168,
308 "sources": [
309 {
310 "id": "osu-root-zone",
311 "claim": "Calibrate probes weekly"
312 }
313 ]
314 },
315 {
316 "id": "air-stone",
317 "label": "Clean the air stone",
318 "everyHours": 168,
319 "resets": [
320 "action:airstone"
321 ],
322 "sources": []
323 },
324 {
325 "id": "roots",
326 "label": "Check the roots (brown or slimy = Pythium); thin or harvest",
327 "everyHours": 168,
328 "sources": [
329 {
330 "id": "cornell-cea-spinach",
331 "claim": "Cooling the solution to 20 °C against Pythium"
332 },
333 {
334 "id": "cornell-gpn-2016",
335 "claim": "Baby-leaf spinach is much more susceptible to Pythium root rot"
336 }
337 ],
338 "why": "Brown or slimy roots are Pythium, which warm water favours"
339 },
340 {
341 "id": "tower",
342 "label": "Tower: top emitter not clogged, every level wet, no algae on lit parts",
343 "everyHours": 168,
344 "needs": "tower",
345 "sources": []
346 },
347 {
348 "id": "change",
349 "label": "Full reservoir change and wipe down",
350 "everyHours": 336,
351 "resets": [
352 "action:change"
353 ],
354 "sources": [],
355 "why": "A fresh reservoir resets salts and slime (cadence is grower consensus; Ohio State: discard when problems arise)"
356 }
357 ],
358 "sources": {
359 "ph": [
360 {
361 "id": "hydrobuilder-spinach",
362 "claim": "pH 5.5–6.5"
363 },
364 {
365 "id": "osu-root-zone",
366 "claim": "Leafy greens pH 5.5–6.5"
367 }
368 ],
369 "waterTemp": [
370 {
371 "id": "cornell-cea-spinach",
372 "claim": "Solution cooled to 20 °C against Pythium"
373 },
374 {
375 "id": "osu-root-zone",
376 "claim": "Solution 20–22 °C"
377 }
378 ],
379 "airTemp": [
380 {
381 "id": "cornell-cea-spinach",
382 "claim": "Air 20–25 °C"
383 }
384 ],
385 "lightHours": [
386 {
387 "id": "hydrobuilder-spinach",
388 "claim": "10–14 h of light"
389 }
390 ],
391 "ec": [
392 {
393 "id": "osu-root-zone",
394 "claim": "Leafy greens EC 1.0–2.5 dS/m"
395 }
396 ]
397 }
398 },
399 {
400 "id": "harvest",
401 "name": "Harvest",
402 "bands": {
403 "ph": [
404 5.5,
405 6.5
406 ],
407 "ec": [
408 1.8,
409 2.3
410 ],
411 "waterTemp": [
412 18,
413 20
414 ],
415 "airTemp": [
416 20,
417 24
418 ],
419 "lightHours": [
420 10,
421 14
422 ]
423 },
424 "reminders": [
425 {
426 "id": "light",
427 "label": "Light ran",
428 "everyHours": 24,
429 "sources": [
430 {
431 "id": "hydrobuilder-spinach",
432 "claim": "10–14 h of light"
433 }
434 ],
435 "why": "Hours of light a day set the daily light integral"
436 },
437 {
438 "id": "level",
439 "label": "Water level; look for wilting",
440 "everyHours": 24,
441 "resets": [
442 "measurement:level",
443 "action:topoff"
444 ],
445 "sources": [],
446 "why": "A drifting level means plants drink faster than the tank refills"
447 },
448 {
449 "id": "pump",
450 "label": "Pump running continuously?",
451 "everyHours": 24,
452 "needs": "pump",
453 "resets": [
454 "action:pumpcheck"
455 ],
456 "sources": [
457 {
458 "id": "uky-hydro-lettuce",
459 "claim": "Lettuce data: NFT keeps a constant film, about 1–2 L/min per channel on a 1–3% slope"
460 }
461 ],
462 "why": "Extension sources describe continuous flow; an on/off cycle (such as 15 on, 15 off) is unsourced"
463 },
464 {
465 "id": "measure",
466 "label": "Measure pH, EC and water temperature",
467 "everyHours": 24,
468 "resets": [
469 "measurement:ph",
470 "measurement:ec",
471 "measurement:waterTemp"
472 ],
473 "sources": [
474 {
475 "id": "osu-root-zone",
476 "claim": "Measure pH, EC and dissolved oxygen daily"
477 }
478 ],
479 "why": "pH and EC drift",
480 "tower": 12
481 },
482 {
483 "id": "bolting",
484 "label": "Stalk forming? Look at the side camera (watch when air has been above 24 C)",
485 "everyHours": 48,
486 "sources": [
487 {
488 "id": "osa-spinach-manual",
489 "claim": "Older lines bolt at 14 h or more, modern lines at about 16 h"
490 },
491 {
492 "id": "uaf-spinach",
493 "claim": "Photoperiod and bolting response"
494 }
495 ],
496 "why": "Long days and heat make spinach bolt"
497 },
498 {
499 "id": "calibrate",
500 "label": "Calibrate the pH and EC probes (4.0 and 7.0 buffers, 1413 µS/cm)",
501 "everyHours": 168,
502 "sources": [
503 {
504 "id": "osu-root-zone",
505 "claim": "Calibrate probes weekly"
506 }
507 ]
508 },
509 {
510 "id": "harvest",
511 "label": "Cut and come again: harvest outer leaves",
512 "everyHours": 168,
513 "resets": [
514 "action:harvest"
515 ],
516 "sources": [
517 {
518 "id": "cornell-cea-spinach",
519 "claim": "A second cutting is beneficial"
520 }
521 ],
522 "why": "Cutting the outer leaves lets the plant regrow"
523 },
524 {
525 "id": "change",
526 "label": "Full reservoir change and wipe down",
527 "everyHours": 336,
528 "resets": [
529 "action:change"
530 ],
531 "sources": [],
532 "why": "A fresh reservoir resets salts and slime"
533 }
534 ],
535 "sources": {
536 "ph": [
537 {
538 "id": "hydrobuilder-spinach",
539 "claim": "pH 5.5–6.5"
540 }
541 ],
542 "waterTemp": [
543 {
544 "id": "cornell-cea-spinach",
545 "claim": "Solution cooled to 20 °C against Pythium"
546 }
547 ],
548 "airTemp": [
549 {
550 "id": "cornell-cea-spinach",
551 "claim": "Air 20–25 °C"
552 }
553 ],
554 "lightHours": [
555 {
556 "id": "hydrobuilder-spinach",
557 "claim": "10–14 h of light"
558 }
559 ]
560 }
561 }
562 ],
563 "references": [
564 {
565 "id": "cornell-cea-spinach",
566 "title": "Spinach final report",
567 "publisher": "Cornell CEA",
568 "url": "https://cea.cals.cornell.edu/files/2019/07/Spinachfinal-report.pdf",
569 "kind": "extension",
570 "accessed": "2026-10-26",
571 "topics": [
572 "light",
573 "climate",
574 "nutrients",
575 "timeline",
576 "problems"
577 ],
578 "covers": "Cornell's hydroponic spinach trials: light, air and solution temperature, spacing, density and a second cutting."
579 },
580 {
581 "id": "cornell-gpn-2016",
582 "title": "Growing Hydroponic Leafy Greens (GPN, October 2016)",
583 "publisher": "Cornell CEA (Mattson) / Greenhouse Product News",
584 "url": "https://cea.cals.cornell.edu/files/2019/06/GPNOctober2016_LeafyGreens.pdf",
585 "kind": "extension",
586 "accessed": "2026-10-27",
587 "topics": [
588 "timeline",
589 "light",
590 "nutrients",
591 "climate",
592 "problems"
593 ],
594 "covers": "Rockwool sub-irrigation, seedlings fed from day 0, seedling light and DLI, temperature, transplant at 3–4 true leaves; baby-leaf spinach is more susceptible to Pythium. Lettuce data unless noted."
595 },
596 {
597 "id": "uaf-spinach",
598 "title": "It Grows in Alaska: spinach",
599 "publisher": "University of Alaska Fairbanks",
600 "url": "https://itgrowsinalaska.community.uaf.edu/?p=1246",
601 "kind": "extension",
602 "accessed": "2026-10-26",
603 "topics": [
604 "problems",
605 "light"
606 ],
607 "covers": "How day length triggers spinach to bolt (citing Parlevliet)."
608 },
609 {
610 "id": "mdpi-agronomy-2020",
611 "title": "Agronomy 2020, 10(8):1082",
612 "publisher": "MDPI Agronomy",
613 "url": "https://www.mdpi.com/2073-4395/10/8/1082",
614 "kind": "peer-reviewed",
615 "accessed": "2026-10-26",
616 "topics": [
617 "light"
618 ],
619 "covers": "Daily light integrals tested on spinach."
620 },
621 {
622 "id": "osa-spinach-manual",
623 "title": "Spinach seed production manual",
624 "publisher": "Organic Seed Alliance",
625 "url": "https://projects.sare.org/wp-content/uploads/1626spinach_seed_manual.pdf",
626 "kind": "grower",
627 "accessed": "2026-10-26",
628 "topics": [
629 "problems",
630 "light"
631 ],
632 "covers": "Spinach as a long-day plant and the day length at which lines bolt."
633 },
634 {
635 "id": "hydrobuilder-spinach",
636 "title": "Hydroponic spinach",
637 "publisher": "Hydrobuilder",
638 "url": "https://learn.hydrobuilder.com/hydroponic-spinach/",
639 "kind": "vendor",
640 "accessed": "2026-10-26",
641 "topics": [
642 "nutrients",
643 "light",
644 "timeline"
645 ],
646 "covers": "A retailer's grow guide: pH, hours of light and germination against temperature."
647 },
648 {
649 "id": "rise-spinach",
650 "title": "Hydroponic spinach tips",
651 "publisher": "Rise Gardens",
652 "url": "https://risegardens.com/blogs/communitygarden/hydroponic-spinach-growing-tips-and-tricks",
653 "kind": "vendor",
654 "accessed": "2026-10-26",
655 "topics": [
656 "nutrients"
657 ],
658 "covers": "Practical tips from a home-garden vendor."
659 },
660 {
661 "id": "uky-hydro-lettuce",
662 "title": "Hydroponic Lettuce (CCD-CP-63)",
663 "publisher": "Univ. of Kentucky Extension",
664 "url": "https://publications.ca.uky.edu/sites/publications.ca.uky.edu/files/hydrolettuce.pdf",
665 "kind": "extension",
666 "accessed": "2026-10-27",
667 "topics": [
668 "nutrients",
669 "timeline"
670 ],
671 "covers": "Stabilize rockwool at pH 5.5–5.8, seedlings 2–3 weeks, NFT constant film at 1–2 L/min per channel on a 1–3% slope. Lettuce data."
672 },
673 {
674 "id": "osu-root-zone",
675 "title": "Root Zone Management of Hydroponic Leafy Greens",
676 "publisher": "Ohio State CFAES / UA CEAC",
677 "url": "https://bpb-us-w2.wpmucdn.com/u.osu.edu/dist/7/65051/files/2020/07/Root-Zone-Management-Handout-2019.pdf",
678 "kind": "extension",
679 "accessed": "2026-10-27",
680 "topics": [
681 "nutrients",
682 "climate",
683 "problems"
684 ],
685 "covers": "EC 1.0–2.5 dS/m, pH 5.5–6.5, measure pH, EC and DO daily, calibrate weekly, solution 20–22 °C."
686 },
687 {
688 "id": "uga-c1304",
689 "title": "Greenhouse Spinach … Using Hydroponics (Circular 1304)",
690 "publisher": "Univ. of Georgia Extension",
691 "url": "https://fieldreport.caes.uga.edu/publications/C1304/how-to-grow-greenhouse-spinach-during-georgia-summers-using-hydroponics/",
692 "kind": "extension",
693 "accessed": "2026-10-27",
694 "topics": [
695 "climate",
696 "timeline",
697 "nutrients"
698 ],
699 "covers": "Spinach germination optimum 18–21 °C, growth 15–25 °C, transplant 2–3 weeks after sowing, DWC at 150 ppm N."
700 },
701 {
702 "id": "kaska-2024",
703 "title": "Spinach germination across temperatures (Kaşka & Kaymak)",
704 "publisher": "Turkish Journal of Agriculture and Forestry 48(2), 2024",
705 "url": "https://journals.tubitak.gov.tr/agriculture/vol48/iss2/3",
706 "kind": "peer-reviewed",
707 "accessed": "2026-10-27",
708 "topics": [
709 "climate",
710 "timeline"
711 ],
712 "covers": "Best spinach germination at 20 °C across 9 cultivars; falls above 20 °C, nearly stops at 35 °C."
713 },
714 {
715 "id": "vonrosen-2022",
716 "title": "Spinach germination temperature (von Rosen)",
717 "publisher": "SLU thesis, 2022",
718 "url": "https://stud.epsilon.slu.se/18237/",
719 "kind": "peer-reviewed",
720 "accessed": "2026-10-27",
721 "topics": [
722 "climate",
723 "timeline"
724 ],
725 "covers": "Best spinach germination at 12 °C; inhibited from about 21 °C, worse at 27 °C."
726 }
727 ]
54728 }
Software/so101/src/care-routes.ts +10 −1
@@ -3,6 +3,7 @@
33 import type { IncomingMessage, ServerResponse } from "node:http";
44 import { boltingWatch, currentStage, health, reminders, snapshot, stageHistory, totals, transplantPrompt } from "./care-rules.ts";
55 import { LogError, type CareLog } from "./care-log.ts";
6 import { learnData, renderLearn } from "./care-sources.ts";
67 import { renderCare } from "./care-page.ts";
78 import { calibrateProbe, renderProbes, type ProbeCache } from "./care-probes.ts";
89 import type { Profile } from "./care-profile.ts";
@@ -55,6 +56,14 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path
5556 send(200, "text/html; charset=utf-8", renderCare({ probes, profile: ctx.profile, log: ctx.log, now, owner: me.owner, dosing: me.owner ? ctx.doser?.status() : undefined, pumps: me.owner && ctx.doser ? ((await ctx.pumps?.()) ?? null) : undefined, signedIn: me.signedIn, error: new URL(req.url ?? "/", "http://x").searchParams.get("error")?.slice(0, 200) }));
5657 return true;
5758 }
59 if (req.method === "GET" && pathname === "/care/learn") {

The page can now reach the Learn page, but the terminal can't. Add /care learn in care-terminal.ts that prints the same references by topic, most trusted first, and add the reminder's why and sources to /care/api so /care can show them.

60 send(200, "text/html; charset=utf-8", renderLearn(ctx.profile));
61 return true;
62 }
63 if (req.method === "GET" && pathname === "/care/learn.json") {
64 json(200, learnData(ctx.profile));
65 return true;
66 }
5867 if (req.method === "GET" && pathname === "/care/events") {
5968 res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", "x-accel-buffering": "no" });
6069 res.write("retry: 5000\n\n");
@@ -85,7 +94,7 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path
8594 stage: currentStage(spans).stage.id,
8695 stages: spans.map((s) => ({ id: s.stage.id, from: s.from })),
8796 health: health(ctx.profile, snap, now),
88 reminders: reminders(ctx.profile, snap, now).map((d) => ({ id: d.reminder.id, label: d.reminder.label, everyHours: d.everyHours, dueAt: d.dueAt, snoozedUntil: d.snoozedUntil })),
97 reminders: reminders(ctx.profile, snap, now).map((d) => ({ id: d.reminder.id, label: d.reminder.label, everyHours: d.everyHours, dueAt: d.dueAt, snoozedUntil: d.snoozedUntil, why: d.reminder.why ?? null, sources: d.reminder.sources.map((s) => s.id) })),
8998 prompts: [transplantPrompt(ctx.profile, snap, now), boltingWatch(ctx.profile, snap, now)].filter(Boolean),
9099 totals: totals(snap.entries, now),
91100 entries: snap.entries,
Software/so101/src/care-sources.ts added +78
@@ -0,0 +1,78 @@
1 /** Cited sources: a footnote marker per source with a tap card, an "unsourced" label when a value has none, and the Learn page's grouping. */
2
3 import type { Profile, Reference, Source } from "./care-profile.ts";
4 import { PATH, renderFundamentals, UNCHECKED } from "./care-fundamentals.ts";
5 import { esc } from "./story-markdown.ts";
6
7 export const UNSOURCED = "grower consensus (unsourced)";
8
9 export const KIND_RANK: Record<Reference["kind"], number> = { extension: 0, "peer-reviewed": 1, grower: 2, vendor: 3 };
10 export const TOPICS = ["light", "nutrients", "climate", "timeline", "problems"] as const;
11
12 export const byTrust = (a: Reference, b: Reference): number => KIND_RANK[a.kind] - KIND_RANK[b.kind] || a.title.localeCompare(b.title);
13
14 /** Numbers sources in the order a page first shows them, so the superscripts read 1, 2, 3. */
15 export function cites(profile: Profile) {
16 const refs = new Map(profile.references.map((r) => [r.id, r]));
17 const numbers = new Map<string, number>();
18 return (sources: readonly Source[] | undefined): string => {
19 if (!sources?.length) return `<span class="dim unsourced">${UNSOURCED}</span>`;
20 return sources
21 .map((s) => {
22 const r = refs.get(s.id);
23 if (!r) return "";
24 if (!numbers.has(r.id)) numbers.set(r.id, numbers.size + 1);
25 const n = numbers.get(r.id)!;
26 return `<details class="cite" name="cite"><summary aria-label="Source ${n}: ${esc(r.title)}"><sup>${n}</sup></summary><span class="card"><strong>${esc(r.title)}</strong><br>${esc(r.publisher)} · ${esc(r.kind)}<br>${esc(s.claim)}<br><a href="${esc(r.url)}" target="_blank" rel="noopener noreferrer">Read (opens in a new tab)</a></span></details>`;
27 })
28 .join("");
29 };
30 }
31
32 export const CITE_STYLE = `
33 details.cite { display: inline; flex-basis: auto; } details.cite summary { display: inline-flex; align-items: center; justify-content: center; min-width: 44px; min-height: 44px; padding: 0; cursor: pointer; vertical-align: middle; } details.cite summary:focus-visible { outline: 2px solid var(--link); outline-offset: -4px; border-radius: 8px; } details.cite sup { font-size: .8rem; }
outdated

summary has min-height:0 and padding:0 .15rem around a .75rem <sup>: the tap target is roughly 8x12px. On a phone this cannot be pressed reliably, and 21 of them sit next to each other on /care. Give it a 44x44px hit area (inline-flex, min-width/min-height 44px, centered) and a visible :focus-visible outline.

34 details.cite .card { display: block; box-sizing: border-box; width: 100%; max-width: 100%; margin: .2rem 0; background: var(--panel); color: var(--fg); border: 1px solid var(--line); border-radius: 8px; padding: .5rem .7rem; font-size: .9rem; overflow-wrap: anywhere; }
outdated

The card is position:absolute; left:0; min-width:14rem inside the cite, so a marker near the right edge of a 360px screen pushes the card off-screen and creates horizontal scroll. Anchor it to the list item/panel (position:relative there) with left:0; right:0; max-width:calc(100vw - 2rem), or render it as a block under the line. Cards also never close each other; a second tap on another marker leaves both open over the text.

35 `;
36
37 /** The Learn page as data, for the JSON and terminal doors: references by topic (most trusted first), then the Fundamentals path. */
38 export function learnData(profile: Profile) {
39 return {
40 topics: TOPICS.map((topic) => ({ topic, references: profile.references.filter((r) => r.topics.includes(topic)).sort(byTrust).map((r) => ({ id: r.id, title: r.title, publisher: r.publisher, kind: r.kind, url: r.url, covers: r.covers })) })).filter((t) => t.references.length),
41 fundamentals: PATH.map((s) => ({ week: s.week, title: s.title, kind: s.kind, minutes: s.minutes, links: s.links })),
42 unchecked: UNCHECKED,
43 };
44 }
45
46 /** The Learn page: the profile's references by topic, extension and peer-reviewed first, each with one line on what it covers. */
47 export function renderLearn(profile: Profile): string {
48 const sections = TOPICS.map((t) => {
49 const items = profile.references.filter((r) => r.topics.includes(t)).sort(byTrust);
50 if (!items.length) return "";
51 return `<section class="panel" aria-labelledby="t-${t}"><h2 id="t-${t}" style="margin-top:0">${esc(t[0]!.toUpperCase() + t.slice(1))}</h2><ul class="list">${items
52 .map((r) => `<li data-ref="${esc(r.id)}" data-kind="${esc(r.kind)}"><span><a href="${esc(r.url)}" target="_blank" rel="noopener noreferrer">${esc(r.title)}<span class="dim"> (new tab)</span></a><br><span class="dim">${esc(r.publisher)} · ${esc(r.kind)} · accessed ${esc(r.accessed)}</span><br>${esc(r.covers)}</span></li>`)
53 .join("")}</ul></section>`;
54 }).join("\n");
55 return `<!doctype html>
56 <html lang="en">
57 <head>
58 <meta charset="utf-8">
59 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
60 <title>Learn · ${esc(profile.name)}</title>
61 <style>:root { color-scheme: dark light; --bg: #0b0d10; --panel: #11161d; --fg: #e8eaed; --dim: #9aa4ae; --line: #2a313a; --link: #79b8ff; }
62 @media (prefers-color-scheme: light) { :root { --bg: #fbfaf7; --panel: #fff; --fg: #1f2328; --dim: #59636e; --line: #d8dee4; --link: #0969da; } }
63 body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.5 ui-sans-serif, system-ui, sans-serif; } main { max-width: 60rem; margin: 0 auto; padding: 1rem; }
64 .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: .6rem .8rem; margin: .6rem 0; } h2 { font-size: 1.1rem; }
65 ol.list, ul.list { list-style: none; margin: 0; padding: 0; } .list li { padding: .5rem 0; border-top: 1px solid var(--line); } .list li:first-child { border-top: 0; } .dim { color: var(--dim); font-size: .9rem; } a { color: var(--link); }</style>
66 </head>
67 <body>
68 <main>
69 <p><a href="/care">← Care</a></p>
70 <h1>Learn about ${esc(profile.name)}</h1>
71 <p class="dim">Where the app's ranges and reminders come from, most trusted first. Anything marked "${UNSOURCED}" has no reference yet.</p>
72 ${renderFundamentals()}
73 ${sections}
74 </main>
75 </body>
76 </html>
77 `;
78 }
Software/so101/src/care-terminal.test.ts +33 −1
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import { CareLog } from "./care-log.ts";
66 import { BUILT_IN_DIR, loadProfiles } from "./care-profile.ts";
7 import { CARE_USAGE, careCommand, careLines } from "./care-terminal.ts";
7 import { CARE_USAGE, careCommand, careLines, learnLines } from "./care-terminal.ts";
88 import { sign } from "./identity.ts";
99 import { Lobby } from "./lobby.ts";
1010 import { startRelay, type RunningRelay } from "./relay.ts";
@@ -150,3 +150,35 @@ test("/probes through the relay: anyone reads, the owner calibrates, a web viewe
150150 expect(page.status).toBe(200);
151151 expect(performance.now() - began).toBeLessThan(500);
152152 });
153
154 test("/care learn asks for the references and prints them by topic, most trusted first, with their URLs", () => {
155 expect(typed(emptyRoom(), "/care learn")).toEqual({ care: { method: "GET", path: "/care/learn.json" } });
156 expect(typed(emptyRoom(), "/care learn now")).toEqual({ print: CARE_USAGE });
157 expect(CARE_USAGE).toContain("/care learn");
158 const lines = learnLines({
159 topics: [{ topic: "light", references: [{ title: "A", publisher: "P", kind: "extension", url: "https://a.example/x" }, { title: "B", publisher: "Q", kind: "vendor", url: "https://b.example/y" }] }],
160 fundamentals: [{ week: 1, title: "Photosynthesis", minutes: 60, links: [{ url: "https://c.example/z" }] }],
161 unchecked: ["a book"],
162 });
163 expect(lines[0]).toBe("light");
164 expect(lines.indexOf(" https://a.example/x")).toBeLessThan(lines.indexOf(" https://b.example/y"));
165 expect(lines.join("\n")).toContain("week 1: Photosynthesis · about 60 min");
166 expect(lines.at(-1)).toContain("not checked");
167 });
168
169 test("/care shows a reminder's why and its source ids, or that it is unsourced", () => {
170 const lines = careLines({ stage: "s", health: { ok: true, lines: [] }, reminders: [{ id: "a", label: "A", dueAt: 1, why: "because", sources: ["osu-root-zone"] }, { id: "b", label: "B", dueAt: 1, sources: [] }], entries: [] }, 0).join("\n");
171 expect(lines).toContain("why: because");
172 expect(lines).toContain("sources: osu-root-zone");
173 expect(lines).toContain("sources: grower consensus (unsourced)");
174 });
175
176 test("GET /care/learn.json and /care/api carry the sources", async () => {
177 const { base } = await start();
178 const learn = (await (await fetch(`${base}/care/learn.json`)).json()) as { topics: { references: { url: string }[] }[]; fundamentals: unknown[] };
179 expect(learn.topics.length).toBeGreaterThan(0);
180 expect(learn.topics[0]!.references[0]!.url).toStartWith("https://");
181 expect(learn.fundamentals.length).toBeGreaterThan(0);
182 const api = (await (await fetch(`${base}/care/api`)).json()) as { reminders: { sources: string[]; why: string | null }[] };
183 expect(api.reminders.some((r) => r.sources.length > 0)).toBe(true);
184 });
Software/so101/src/care-terminal.ts +23 −2
@@ -17,7 +17,7 @@ export interface CareAsk {
1717 timeoutMs?: number;
1818 }
1919
20 export const CARE_USAGE = "usage: /care · /care log <ph|ec|waterTemp|airTemp|level|lightHours> <value> · /care did <action> [amount] · /care note <text> · /care problem <bolting|tip burn|root rot|algae|yellowing|leggy> [detail] · /care done <reminder> · /care snooze <reminder> [hours] · /care delete <entry id> · /care dosing · /care dose <nutrientA|nutrientB|phDown> [ml] · /care mode <loop> <off|suggest|auto> · /care resume · /care pumps · /care wire <pump> gpio <line> [low] | shelly <host> | none · /care prime <pump> [seconds] · /care calibrate <pump> <ml> · /care float <line|none> [low] [cap seconds] · /care topoff (dosing is the owner's; a pump is nutrientA, nutrientB, phDown or topOff)";
20 export const CARE_USAGE = "usage: /care · /care log <ph|ec|waterTemp|airTemp|level|lightHours> <value> · /care did <action> [amount] · /care note <text> · /care problem <bolting|tip burn|root rot|algae|yellowing|leggy> [detail] · /care done <reminder> · /care snooze <reminder> [hours] · /care delete <entry id> · /care learn · /care dosing · /care dose <nutrientA|nutrientB|phDown> [ml] · /care mode <loop> <off|suggest|auto> · /care resume · /care pumps · /care wire <pump> gpio <line> [low] | shelly <host> | none · /care prime <pump> [seconds] · /care calibrate <pump> <ml> · /care float <line|none> [low] [cap seconds] · /care topoff (dosing is the owner's; a pump is nutrientA, nutrientB, phDown or topOff)";
2121
2222 export const PROBES_USAGE = "usage: /probes · /probes calibrate <ph7|ph4|ec1413> [solution tempC] (owner)";
2323
@@ -57,6 +57,8 @@ export function careCommand(words: string[], tail: string): CareAsk | { print: s
5757 return /^[a-z0-9-]{1,32}$/.test(a) && words.length <= 3 && (!b || Number.isFinite(num(b))) ? post("/care/reminder", { id: a, action: "snooze", hours: b ? num(b) : 24 }) : usage;
5858 case "delete":
5959 return /^\d{1,9}$/.test(a) && words.length === 2 ? { method: "DELETE", path: `/care/log/${a}`, body: {} } : usage;
60 case "learn":
61 return words.length === 1 ? { method: "GET", path: "/care/learn.json" } : usage;
6062 case "dosing":
6163 return words.length === 1 ? { method: "GET", path: "/care/dosing" } : usage;
6264 case "dose":
@@ -134,7 +136,7 @@ const day = (at: number) => new Date(at).toISOString().replace("T", " ").slice(0
134136
135137 /** GET /care/api as lines: the stage, the health strip, what is due, and the latest entries with their ids. */
136138 export function careLines(body: unknown, now = Date.now()): string[] {
137 const b = (body ?? {}) as { stage?: unknown; health?: { ok?: unknown; lines?: unknown[] }; reminders?: { id?: unknown; label?: unknown; dueAt?: unknown; snoozedUntil?: unknown }[]; prompts?: unknown[]; entries?: Record<string, unknown>[] };
139 const b = (body ?? {}) as { stage?: unknown; health?: { ok?: unknown; lines?: unknown[] }; reminders?: { id?: unknown; label?: unknown; dueAt?: unknown; snoozedUntil?: unknown; why?: unknown; sources?: unknown[] }[]; prompts?: unknown[]; entries?: Record<string, unknown>[] };
138140 const lines = [`Stage: ${String(b.stage ?? "?")} · ${b.health?.ok ? "healthy" : "needs attention"} (typical ranges, not guaranteed)`];
139141 for (const l of b.health?.lines ?? []) lines.push(` ${String(l)}`);
140142 for (const p of b.prompts ?? []) lines.push(` ${String(p)}`);
@@ -142,6 +144,8 @@ export function careLines(body: unknown, now = Date.now()): string[] {
142144 for (const r of (b.reminders ?? []).slice(0, 8)) {
143145 const state = r.snoozedUntil ? `snoozed until ${day(Number(r.snoozedUntil))}` : Number(r.dueAt) <= now ? "OVERDUE" : `due ${day(Number(r.dueAt))}`;
144146 lines.push(` ${String(r.id)}: ${String(r.label)} · ${state}`);
147 if (r.why) lines.push(` why: ${String(r.why)}`);
148 lines.push(` sources: ${r.sources?.length ? r.sources.map(String).join(", ") : "grower consensus (unsourced)"}`);
145149 }
146150 lines.push("Log book (newest last):");
147151 for (const e of (b.entries ?? []).slice(-5)) {
@@ -150,3 +154,20 @@ export function careLines(body: unknown, now = Date.now()): string[] {
150154 }
151155 return lines;
152156 }
157
158 /** GET /care/learn.json as lines: the references by topic, most trusted first, with their URLs, then the Fundamentals path. */
159 export function learnLines(body: unknown): string[] {
160 const b = (body ?? {}) as { topics?: { topic?: unknown; references?: { title?: unknown; publisher?: unknown; kind?: unknown; url?: unknown }[] }[]; fundamentals?: { week?: unknown; title?: unknown; minutes?: unknown; links?: { url?: unknown }[] }[]; unchecked?: unknown[] };
161 const lines: string[] = [];
162 for (const t of b.topics ?? []) {
163 lines.push(String(t.topic));
164 for (const r of t.references ?? []) lines.push(` ${String(r.title)} · ${String(r.publisher)} · ${String(r.kind)}`, ` ${String(r.url)}`);
165 }
166 lines.push("Fundamentals (week one first):");
167 for (const s of b.fundamentals ?? []) {
168 lines.push(` week ${String(s.week)}: ${String(s.title)}${s.minutes ? ` · about ${String(s.minutes)} min` : ""}`);
169 for (const l of s.links ?? []) lines.push(` ${String(l.url)}`);
170 }
171 if (b.unchecked?.length) lines.push(`Optional, not checked: ${b.unchecked.map(String).join("; ")}`);
172 return lines;
173 }
Software/so101/src/care.test.ts +67 −4
@@ -98,7 +98,7 @@ test("the transplant moves the stage, changes the reminders, and keeps history",
9898 expect(ids).toContain("pump");
9999 expect(ids).toContain("tower");
100100 // a small reservoir moves fast: measure and top off sooner in a tower
101 expect(due(log, clock.now, "measure")!.everyHours).toBe(36);
101 expect(due(log, clock.now, "measure")!.everyHours).toBe(12);
102102 expect(due(log, clock.now, "topoff")!.everyHours).toBe(24);
103103 });
104104
@@ -109,7 +109,7 @@ test("without a pump there is no pump reminder; a different system keeps the def
109109 const ids = reminders(spinach, snapshot(log), T0).map((d) => d.reminder.id);
110110 expect(ids).not.toContain("pump");
111111 expect(ids).not.toContain("tower");
112 expect(due(log, T0, "measure")!.everyHours).toBe(60);
112 expect(due(log, T0, "measure")!.everyHours).toBe(24);
113113 });
114114
115115 test("topping off resets the top-off reminder; done, snooze and an edited cadence work", () => {
@@ -120,7 +120,7 @@ test("topping off resets the top-off reminder; done, snooze and an edited cadenc
120120 log.add({ type: "action", what: "topoff" });
121121 expect(due(log, clock.now, "topoff")!.dueAt).toBe(T0 + 49 * H);
122122 log.add({ type: "action", what: "done", ref: "calibrate" });
123 expect(due(log, clock.now, "calibrate")!.dueAt).toBe(clock.now + 720 * H);
123 expect(due(log, clock.now, "calibrate")!.dueAt).toBe(clock.now + 168 * H);
124124 log.snooze("roots", 48);
125125 expect(due(log, clock.now + 10 * H, "roots")!.snoozedUntil).toBe(clock.now + 48 * H);
126126 log.setEvery("roots", 24);
@@ -161,7 +161,7 @@ test("totals, and the ready-to-transplant prompt from the sow date", () => {
161161 const { clock, log } = book();
162162 log.add({ type: "action", what: "sow" });
163163 expect(transplantPrompt(spinach, snapshot(log), T0 + 10 * D)).toBeNull();
164 expect(transplantPrompt(spinach, snapshot(log), T0 + 15 * D)).toMatch(/Day 15.*2-4 true leaves/);
164 expect(transplantPrompt(spinach, snapshot(log), T0 + 15 * D)).toMatch(/Day 15.*3–4 true leaves/);
165165 clock.now = T0 + 16 * D;
166166 log.add(TOWER);
167167 expect(transplantPrompt(spinach, snapshot(log), T0 + 20 * D)).toBeNull();
@@ -250,3 +250,66 @@ test("a write publishes 'care changed' to /care/events, and a bad form comes bac
250250 server.close();
251251 }
252252 });
253
254 const lists = () => {
outdated

This one test checks resolution, https, the unsourced label, trust order, week order and the unchecked block. Split it into a test per behaviour, so a failure names what broke.

255 const out = [spinach.light.sources, spinach.light.warnSources, spinach.timeline.sources];
256 for (const st of spinach.stages) {
257 for (const k of Object.keys(st.bands)) out.push(st.sources[k as keyof typeof st.sources] ?? []);
258 for (const r of st.reminders) out.push(r.sources);
259 }
260 return out;
261 };
262 const sourcesMod = () => import("./care-sources.ts");
263
264 test("every source a spinach value cites resolves to a reference", () => {
265 const ids = new Set(spinach.references.map((r) => r.id));
266 for (const l of lists()) for (const s of l) expect(ids.has(s.id)).toBe(true);
267 });
268
269 test("every spinach reference is an https link", () => {
270 for (const r of spinach.references) expect(r.url.startsWith("https://")).toBe(true);
271 });
272
273 test("a value with no source says it is unsourced, and a sourced one has a footnote card", async () => {
274 const { UNSOURCED } = await sourcesMod();
275 const html = renderCare({ profile: spinach, log: book().log, now: T0, owner: false, signedIn: null });
276 expect(html).toContain(UNSOURCED);
277 expect(html).toContain('class="cite"');
278 });
279
280 test("the Learn page lists each topic's references most trusted first", async () => {
281 const { renderLearn, byTrust } = await sourcesMod();
282 const rank: Record<string, number> = { extension: 0, "peer-reviewed": 1, grower: 2, vendor: 3 };
283 const learn = renderLearn(spinach);
284 for (const section of learn.split("<section").slice(1)) {
285 const r = [...section.matchAll(/data-kind="([^"]+)"/g)].map((m) => rank[m[1]!]!);
286 expect(r).toEqual([...r].sort((x, y) => x - y));
287 }
288 expect(learn).toContain("cornell-cea-spinach");
289 expect(spinach.references.filter((r) => r.topics.includes("light")).sort(byTrust)[0]!.kind).toBe("extension");
290 });
291
292 test("the Learn page's Fundamentals path runs week one before week two", async () => {
293 const learn = (await sourcesMod()).renderLearn(spinach);
294 expect(learn.indexOf("Week one")).toBeGreaterThan(-1);
295 expect(learn.indexOf("Week one")).toBeLessThan(learn.indexOf("Week two"));
296 });
297
298 test("the Learn page lists unchecked items without a link", async () => {
299 const learn = (await sourcesMod()).renderLearn(spinach);
300 expect(learn).toContain("Optional, not checked");
301 expect(learn).toContain("Taiz &amp; Zeiger");
302 expect(learn).not.toMatch(/<a [^>]*>[^<]*Taiz/);
303 });
304
305 test("a profile refuses a reference that is not https", () => {
306 expect(() => parseProfile({ ...spinach, references: [{ ...spinach.references[0], url: "http://x.org/a" }] })).toThrow(/https/);
307 });
308
309 test("a profile refuses a source id with no matching reference", () => {
310 expect(() => parseProfile({ ...spinach, timeline: { sources: [{ id: "nowhere", claim: "x" }] } })).toThrow(/no reference/);
311 });
312
313 test("a profile refuses two references with the same id", () => {
314 expect(() => parseProfile({ ...spinach, references: [spinach.references[0], spinach.references[0]] })).toThrow(/share an id/);
315 });
Software/so101/src/terminal.ts +2 −2
@@ -75,7 +75,7 @@ import { readTuning, tuningSentence, type TuningView } from "./tuning-panel.ts";
7575 import { JOINTS } from "./sts3215.ts";
7676 import { headlineValue, hopLines, NO_HOPS, P95_RULE, readPrecision, sampleSize, sparkline, type PrecisionPanel } from "./precision-panel.ts";
7777 import { probeLines } from "./care-probes.ts";
78 import { careCommand, careLines, dosingLines, pumpLines, pumpSaid, probesCommand, type CareAsk } from "./care-terminal.ts";
78 import { careCommand, careLines, learnLines, dosingLines, pumpLines, pumpSaid, probesCommand, type CareAsk } from "./care-terminal.ts";
7979 import { presenceText } from "./ui/lobby-panel.js";
8080 import { entryLine, jointRows, linkRows, stateRows } from "./ui/machine.js";
8181
@@ -198,7 +198,7 @@ export function careReader(env: Env, fetcher: typeof fetch = fetch): ReadCare {
198198 if (!r.ok) return { error: plain((body as { error?: unknown } | null)?.error ?? `status ${r.status}`), lines: [] };
199199 if (ask.path === "/care/probes/calibrate") return { error: null, lines: ["probe captured", ...probeLines({ view: body }).map(plain)] };
200200 if (write) return { error: null, lines: [plain(ask.path === "/care/dosing/pump" ? pumpSaid(body) : ask.path.startsWith("/care/dosing") ? "dosing: done" : "care saved")] };
201 return { error: null, lines: (ask.path === "/care/dosing" ? dosingLines(body) : ask.path === "/care/probes" ? probeLines(body) : ask.path === "/care/dosing/pumps" ? pumpLines(body) : careLines(body)).map(plain) };
201 return { error: null, lines: (ask.path === "/care/learn.json" ? learnLines(body) : ask.path === "/care/dosing" ? dosingLines(body) : ask.path === "/care/probes" ? probeLines(body) : ask.path === "/care/dosing/pumps" ? pumpLines(body) : careLines(body)).map(plain) };
202202 } catch (err) {
203203 return { error: plain((err as Error).message), lines: [] };
204204 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs