Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
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
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
smith/arm:smith/626-cited-sources-in-the-care-app-every-targ → main 11 commits fork 6 threads · 6 unresolved
Software/so101/README.md +3 −2
| @@ -155,16 +155,17 @@ echo 'so101 ALL=(root) NOPASSWD: /usr/bin/systemctl stop [email protected], | ||
| 155 | 155 | 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). |
| 156 | 156 | |
| 157 | 157 | - **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. | |
| 159 | 159 | - **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. |
| 160 | 160 | - **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. | |
| 161 | 162 | - **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. |
| 162 | 163 | - **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. |
| 163 | 164 | - **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. |
| 164 | 165 | |
| 165 | 166 | ### pH and EC probes (on /care) |
| 166 | 167 | |
| 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. | |
| 168 | 169 | |
| 169 | 170 | 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`. |
| 170 | 171 |
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"; | ||
| 8 | 8 | import { renderPumps } from "./pumps-card.ts"; |
| 9 | 9 | import { MODES } from "./doser.ts"; |
| 10 | 10 | import { renderSignedIn, SIGNED_IN_STYLE, type SignedIn } from "./signed-in.ts"; |
| 11 | import { CITE_STYLE, cites } from "./care-sources.ts"; | |
| 11 | 12 | import { esc } from "./story-markdown.ts"; |
| 12 | 13 | |
| 13 | 14 | const DAY = 86_400_000; |
| @@ -48,11 +49,11 @@ a { color: var(--link); } a:focus-visible, button:focus-visible { outline: 2px s | ||
| 48 | 49 | `; |
| 49 | 50 | |
| 50 | 51 | /** 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 { | |
| 52 | 53 | const { name, unit } = LABELS[kind]; |
| 53 | 54 | const pts = readings(snap.entries, kind).filter((r) => r.at > now - 30 * DAY && r.at <= now); |
| 54 | 55 | 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>`; | |
| 56 | 57 | const values = pts.map((p) => p.value).concat(band ?? []); |
| 57 | 58 | let lo = Math.min(...values), hi = Math.max(...values); |
| 58 | 59 | const pad = (hi - lo || 1) * 0.15; |
| @@ -63,7 +64,7 @@ function chart(kind: Measure, snap: Snapshot, band: Band | undefined, now: numbe | ||
| 63 | 64 | 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)"/>` : ""; |
| 64 | 65 | const last = pts[pts.length - 1]!; |
| 65 | 66 | 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> | |
| 67 | 68 | <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} |
| 68 | 69 | <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> |
| 69 | 70 | <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 | ||
| 139 | 140 | .join("")}</ul>${pumps !== undefined ? renderPumps(pumps) : ""}</section>` |
| 140 | 141 | : ""; |
| 141 | 142 | |
| 143 | const cite = cites(profile); | |
| 142 | 144 | const dueRows = due |
| 143 | 145 | .map((d) => { |
| 144 | 146 | const late = d.overdueMs > 0 && !d.snoozedUntil; |
| 145 | 147 | 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)}`; |
| 146 | 148 | 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>`; | |
| 148 | 150 | }) |
| 149 | 151 | .join(""); |
| 150 | 152 | |
| 151 | 153 | 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(""); | |
| 153 | 155 | |
| 154 | 156 | let lastStage = ""; |
| 155 | 157 | const rows = [...snap.entries] |
| @@ -170,7 +172,7 @@ ${dosing.simulated ? `<p class="dim">No pumps are wired yet: a dose is only note | ||
| 170 | 172 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> |
| 171 | 173 | <title>Grow care · ${esc(profile.name)}</title> |
| 172 | 174 | <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> | |
| 174 | 176 | </head> |
| 175 | 177 | <body> |
| 176 | 178 | <a class="skip" href="#main">Skip to the care page</a> |
| @@ -185,7 +187,7 @@ ${probes} | ||
| 185 | 187 | ${dosingCard} |
| 186 | 188 | <h2>Reminders</h2><div class="panel"><ul class="list">${dueRows}</ul></div> |
| 187 | 189 | <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> | |
| 189 | 191 | <div class="charts">${charts}</div> |
| 190 | 192 | <h2>Log book</h2><div class="panel"><ul class="list">${rows || '<li class="dim">Nothing logged yet.</li>'}</ul></div> |
| 191 | 193 | </main> |
Software/so101/src/care-profile.ts +62 −3
| @@ -11,9 +11,32 @@ export type Measure = (typeof MEASURES)[number]; | ||
| 11 | 11 | |
| 12 | 12 | export type Band = readonly [number, number]; |
| 13 | 13 | |
| 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 | ||
| 14 | 33 | export interface Reminder { |
| 15 | 34 | id: string; |
| 16 | 35 | 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[]; | |
| 17 | 40 | everyHours: number; |
| 18 | 41 | /** Replaces everyHours in a vertical tower, whose small reservoir drifts faster. */ |
| 19 | 42 | tower?: number; |
| @@ -27,6 +50,8 @@ export interface Stage { | ||
| 27 | 50 | id: string; |
| 28 | 51 | name: string; |
| 29 | 52 | bands: Partial<Record<Measure, Band>>; |
| 53 | /** Sources per band; a band with none is shown as unsourced. */ | |
| 54 | sources: Partial<Record<Measure, Source[]>>; | |
| 30 | 55 | reminders: Reminder[]; |
| 31 | 56 | transplantCheck?: { afterDays: Band; label: string }; |
| 32 | 57 | /** 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 { | ||
| 37 | 62 | id: string; |
| 38 | 63 | name: string; |
| 39 | 64 | 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[]; | |
| 41 | 68 | stages: Stage[]; |
| 42 | 69 | } |
| 43 | 70 | |
| @@ -66,11 +93,34 @@ const obj = (v: unknown, what: string): Record<string, unknown> => { | ||
| 66 | 93 | return v as Record<string, unknown>; |
| 67 | 94 | }; |
| 68 | 95 | |
| 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`); | |
| 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 | ||
| 69 | 116 | /** Any JSON → a profile; throws a ProfileError saying what is wrong. */ |
| 70 | 117 | export function parseProfile(input: unknown): Profile { |
| 71 | 118 | const p = obj(input, "a profile"); |
| 72 | 119 | const light = obj(p.light, "light"); |
| 73 | 120 | 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"); | |
| 74 | 124 | const stages = p.stages.map((raw): Stage => { |
| 75 | 125 | const s = obj(raw, "a stage"); |
| 76 | 126 | const bandsIn = obj(s.bands ?? {}, "bands"); |
| @@ -79,6 +129,12 @@ export function parseProfile(input: unknown): Profile { | ||
| 79 | 129 | if (!MEASURES.includes(k as Measure)) throw new ProfileError(`no such measure "${k}"`); |
| 80 | 130 | bands[k as Measure] = band(v, `${k} band`); |
| 81 | 131 | } |
| 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 | } | |
| 82 | 138 | if (!Array.isArray(s.reminders) || s.reminders.length > 20) throw new ProfileError("a stage has up to 20 reminders"); |
| 83 | 139 | const seen = new Set<string>(); |
| 84 | 140 | const reminders = s.reminders.map((rr): Reminder => { |
| @@ -94,7 +150,7 @@ export function parseProfile(input: unknown): Profile { | ||
| 94 | 150 | if (r.needs !== undefined && r.needs !== "pump" && r.needs !== "tower") throw new ProfileError('needs is "pump" or "tower"'); |
| 95 | 151 | const resets = r.resets ?? []; |
| 96 | 152 | 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[] }; | |
| 98 | 154 | }); |
| 99 | 155 | const tc = s.transplantCheck === undefined ? undefined : obj(s.transplantCheck, "transplantCheck"); |
| 100 | 156 | const sp = s.sprout === undefined ? undefined : obj(s.sprout, "sprout"); |
| @@ -102,6 +158,7 @@ export function parseProfile(input: unknown): Profile { | ||
| 102 | 158 | id: id(s.id, "a stage"), |
| 103 | 159 | name: words(s.name, "a stage's name", 60), |
| 104 | 160 | bands, |
| 161 | sources, | |
| 105 | 162 | reminders, |
| 106 | 163 | ...(tc ? { transplantCheck: { afterDays: band(tc.afterDays, "afterDays"), label: words(tc.label, "the check's label") } } : {}), |
| 107 | 164 | ...(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 { | ||
| 112 | 169 | id: id(p.id, "a profile"), |
| 113 | 170 | name: words(p.name, "a profile's name", 60), |
| 114 | 171 | 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, | |
| 116 | 175 | stages, |
| 117 | 176 | }; |
| 118 | 177 | } |
Software/so101/src/care-profiles/spinach.json +725 −51
| @@ -1,54 +1,728 @@ | ||
| 1 | 1 | { |
| 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 | } | |
| 53 | 31 | ] |
| 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 | ] | |
| 54 | 728 | } |
Software/so101/src/care-routes.ts +10 −1
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | import type { IncomingMessage, ServerResponse } from "node:http"; |
| 4 | 4 | import { boltingWatch, currentStage, health, reminders, snapshot, stageHistory, totals, transplantPrompt } from "./care-rules.ts"; |
| 5 | 5 | import { LogError, type CareLog } from "./care-log.ts"; |
| 6 | import { learnData, renderLearn } from "./care-sources.ts"; | |
| 6 | 7 | import { renderCare } from "./care-page.ts"; |
| 7 | 8 | import { calibrateProbe, renderProbes, type ProbeCache } from "./care-probes.ts"; |
| 8 | 9 | import type { Profile } from "./care-profile.ts"; |
| @@ -55,6 +56,14 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path | ||
| 55 | 56 | 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) })); |
| 56 | 57 | return true; |
| 57 | 58 | } |
| 59 | if (req.method === "GET" && pathname === "/care/learn") { | |
The page can now reach the Learn page, but the terminal can't. Add |
||
| 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 | } | |
| 58 | 67 | if (req.method === "GET" && pathname === "/care/events") { |
| 59 | 68 | res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", "x-accel-buffering": "no" }); |
| 60 | 69 | res.write("retry: 5000\n\n"); |
| @@ -85,7 +94,7 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path | ||
| 85 | 94 | stage: currentStage(spans).stage.id, |
| 86 | 95 | stages: spans.map((s) => ({ id: s.stage.id, from: s.from })), |
| 87 | 96 | 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) })), | |
| 89 | 98 | prompts: [transplantPrompt(ctx.profile, snap, now), boltingWatch(ctx.profile, snap, now)].filter(Boolean), |
| 90 | 99 | totals: totals(snap.entries, now), |
| 91 | 100 | 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
|
||
| 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 |
||
| 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"; | ||
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { CareLog } from "./care-log.ts"; |
| 6 | 6 | 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"; | |
| 8 | 8 | import { sign } from "./identity.ts"; |
| 9 | 9 | import { Lobby } from "./lobby.ts"; |
| 10 | 10 | import { startRelay, type RunningRelay } from "./relay.ts"; |
| @@ -150,3 +150,35 @@ test("/probes through the relay: anyone reads, the owner calibrates, a web viewe | ||
| 150 | 150 | expect(page.status).toBe(200); |
| 151 | 151 | expect(performance.now() - began).toBeLessThan(500); |
| 152 | 152 | }); |
| 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 { | ||
| 17 | 17 | timeoutMs?: number; |
| 18 | 18 | } |
| 19 | 19 | |
| 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)"; | |
| 21 | 21 | |
| 22 | 22 | export const PROBES_USAGE = "usage: /probes · /probes calibrate <ph7|ph4|ec1413> [solution tempC] (owner)"; |
| 23 | 23 | |
| @@ -57,6 +57,8 @@ export function careCommand(words: string[], tail: string): CareAsk | { print: s | ||
| 57 | 57 | 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; |
| 58 | 58 | case "delete": |
| 59 | 59 | 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; | |
| 60 | 62 | case "dosing": |
| 61 | 63 | return words.length === 1 ? { method: "GET", path: "/care/dosing" } : usage; |
| 62 | 64 | case "dose": |
| @@ -134,7 +136,7 @@ const day = (at: number) => new Date(at).toISOString().replace("T", " ").slice(0 | ||
| 134 | 136 | |
| 135 | 137 | /** GET /care/api as lines: the stage, the health strip, what is due, and the latest entries with their ids. */ |
| 136 | 138 | 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>[] }; | |
| 138 | 140 | const lines = [`Stage: ${String(b.stage ?? "?")} · ${b.health?.ok ? "healthy" : "needs attention"} (typical ranges, not guaranteed)`]; |
| 139 | 141 | for (const l of b.health?.lines ?? []) lines.push(` ${String(l)}`); |
| 140 | 142 | for (const p of b.prompts ?? []) lines.push(` ${String(p)}`); |
| @@ -142,6 +144,8 @@ export function careLines(body: unknown, now = Date.now()): string[] { | ||
| 142 | 144 | for (const r of (b.reminders ?? []).slice(0, 8)) { |
| 143 | 145 | const state = r.snoozedUntil ? `snoozed until ${day(Number(r.snoozedUntil))}` : Number(r.dueAt) <= now ? "OVERDUE" : `due ${day(Number(r.dueAt))}`; |
| 144 | 146 | 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)"}`); | |
| 145 | 149 | } |
| 146 | 150 | lines.push("Log book (newest last):"); |
| 147 | 151 | for (const e of (b.entries ?? []).slice(-5)) { |
| @@ -150,3 +154,20 @@ export function careLines(body: unknown, now = Date.now()): string[] { | ||
| 150 | 154 | } |
| 151 | 155 | return lines; |
| 152 | 156 | } |
| 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", | ||
| 98 | 98 | expect(ids).toContain("pump"); |
| 99 | 99 | expect(ids).toContain("tower"); |
| 100 | 100 | // 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); | |
| 102 | 102 | expect(due(log, clock.now, "topoff")!.everyHours).toBe(24); |
| 103 | 103 | }); |
| 104 | 104 | |
| @@ -109,7 +109,7 @@ test("without a pump there is no pump reminder; a different system keeps the def | ||
| 109 | 109 | const ids = reminders(spinach, snapshot(log), T0).map((d) => d.reminder.id); |
| 110 | 110 | expect(ids).not.toContain("pump"); |
| 111 | 111 | expect(ids).not.toContain("tower"); |
| 112 | expect(due(log, T0, "measure")!.everyHours).toBe(60); | |
| 112 | expect(due(log, T0, "measure")!.everyHours).toBe(24); | |
| 113 | 113 | }); |
| 114 | 114 | |
| 115 | 115 | 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 | ||
| 120 | 120 | log.add({ type: "action", what: "topoff" }); |
| 121 | 121 | expect(due(log, clock.now, "topoff")!.dueAt).toBe(T0 + 49 * H); |
| 122 | 122 | 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); | |
| 124 | 124 | log.snooze("roots", 48); |
| 125 | 125 | expect(due(log, clock.now + 10 * H, "roots")!.snoozedUntil).toBe(clock.now + 48 * H); |
| 126 | 126 | log.setEvery("roots", 24); |
| @@ -161,7 +161,7 @@ test("totals, and the ready-to-transplant prompt from the sow date", () => { | ||
| 161 | 161 | const { clock, log } = book(); |
| 162 | 162 | log.add({ type: "action", what: "sow" }); |
| 163 | 163 | 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/); | |
| 165 | 165 | clock.now = T0 + 16 * D; |
| 166 | 166 | log.add(TOWER); |
| 167 | 167 | 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 | ||
| 250 | 250 | server.close(); |
| 251 | 251 | } |
| 252 | 252 | }); |
| 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 & 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"; | ||
| 75 | 75 | import { JOINTS } from "./sts3215.ts"; |
| 76 | 76 | import { headlineValue, hopLines, NO_HOPS, P95_RULE, readPrecision, sampleSize, sparkline, type PrecisionPanel } from "./precision-panel.ts"; |
| 77 | 77 | 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"; | |
| 79 | 79 | import { presenceText } from "./ui/lobby-panel.js"; |
| 80 | 80 | import { entryLine, jointRows, linkRows, stateRows } from "./ui/machine.js"; |
| 81 | 81 | |
| @@ -198,7 +198,7 @@ export function careReader(env: Env, fetcher: typeof fetch = fetch): ReadCare { | ||
| 198 | 198 | if (!r.ok) return { error: plain((body as { error?: unknown } | null)?.error ?? `status ${r.status}`), lines: [] }; |
| 199 | 199 | if (ask.path === "/care/probes/calibrate") return { error: null, lines: ["probe captured", ...probeLines({ view: body }).map(plain)] }; |
| 200 | 200 | 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) }; | |
| 202 | 202 | } catch (err) { |
| 203 | 203 | return { error: plain((err as Error).message), lines: [] }; |
| 204 | 204 | } |
No test checks this refusal, or the duplicate-id refusal above it. Add a test that feeds
parseProfilea source id with no matching reference and expects /no reference/, and another with two references sharing an id.