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

Migrated from github.com/Trevato/SO-ARM100

workbenchthis machine

Off-due as a real reminder in the care log (follow-up to #639) #642

Merged opened this

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

Merged

quill/arm:quill/641-off-due-as-a-real-reminder-in-the-care-l → main 5 commits fork

  • Merged. quill/641-off-due-as-a-real-reminder-in-the-care-l was merged into main as da3e402.

Review

Sign in to review this change.
8 files changed +161 −14
Software/so101/README.md +1 −1
@@ -160,7 +160,7 @@ The grow box's care app: a plant profile, reminders worked out from it and the l
160160
161161 - **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.
162162 - **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.
163 - **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.
163 - **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. **Switch the grow light off** (`light-off`, src/light-day.ts) is there while the light is logged on: due at the latest `lightOn` plus the schedule's hours as the Pi last said (12 h before it has; held to 10–14 h), gone once a `lightOff` is logged. Its Done logs the `lightOff`, its Snooze is an hour on the page (a new `lightOn` clears it, so last night's snooze never hides today's), its cadence is the schedule's and is not edited here, and its sources are the stage's photoperiod citations. Overdue, it sits on the health strip, in `/care/api` and in the overdue list the grow watchdog reads.
164164 - **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`).
165165 - **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.
166166 - **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, lightOn, lightOff), `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.
Software/so101/src/care-log.ts +3
@@ -6,6 +6,7 @@
66 import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
77 import { dirname } from "node:path";
88 import { MEASURES, type Measure } from "./care-profile.ts";
9 import { LIGHT_OFF } from "./light-day.ts";
910
1011 export const MAX_ENTRIES = 5000;
1112 export const SENSOR_EVERY_MS = 3_600_000;
@@ -182,6 +183,8 @@ export class CareLog {
182183 const entry = { ...body, id: this.data.nextId++ } as Entry;
183184 this.data.entries.push(entry);
184185 this.data.entries.sort((a, b) => a.at - b.at || a.id - b.id);
186 // A new day of light: yesterday's "light still on, snooze" must not hide today's off-time.
187 if (entry.type === "action" && entry.what === "lightOn") delete this.data.snoozes[LIGHT_OFF];
185188 this.save();
186189 return entry;
187190 }
Software/so101/src/care-page.ts +9 −4
@@ -1,5 +1,6 @@
11 /** The grow care page: health strip, reminders, quick-add, charts against the target bands, and the log book as a timeline with stage bands. Every button is a form; the one script only refreshes it live. */
22
3 import { LIGHT_OFF } from "./light-day.ts";
34 import { boltingWatch, currentStage, growing, health, LABELS, ago, readings, reminders, snapshot, stageAt, stageHistory, totals, transplantPrompt, type Snapshot } from "./care-rules.ts";
45 import { ACTIONS, PROBLEMS, SYSTEMS, type Entry, type CareLog } from "./care-log.ts";
56 import { MEASURES, type Band, type Measure, type Profile } from "./care-profile.ts";
@@ -99,14 +100,16 @@ export interface CareView {
99100 /** The Pi's pumps under the dosing card; null when the Pi did not answer. */
100101 pumps?: DosingView | null;
101102 signedIn: SignedIn | null;
103 /** The light's scheduled hours as the Pi last said (the light-off reminder's day). */
104 lightHours?: number | null;
102105 /** A refused form's message, shown at the top. */
103106 error?: string | null;
104107 /** The probe panel, already rendered (src/care-probes.ts); empty without probes. */
105108 probes?: string;
106109 }
107110
108 export function renderCare({ profile, log, now, owner, signedIn, error, dosing, pumps, probes = "" }: CareView): string {
109 const snap = snapshot(log);
111 export function renderCare({ profile, log, now, owner, signedIn, error, dosing, pumps, probes = "", lightHours }: CareView): string {
112 const snap = snapshot(log, lightHours);
110113 const spans = stageHistory(profile, snap.entries, snap.startedAt);
111114 const cur = currentStage(spans);
112115 const h = health(profile, snap, now);
@@ -145,8 +148,10 @@ ${dosing.simulated ? `<p class="dim">No pumps are wired yet: a dose is only note
145148 .map((d) => {
146149 const late = d.overdueMs > 0 && !d.snoozedUntil;
147150 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)}`;
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>` : "";
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>`;
151 // The light's day is hours long: its snooze is an hour, and Done logs the light off.
152 const light = d.reminder.id === LIGHT_OFF;
153 const acts = owner ? `<span>${post("/care/reminder", { id: d.reminder.id, action: "done" }, light ? "It's off" : "Done")} ${post("/care/reminder", { id: d.reminder.id, action: "snooze", hours: light ? "1" : "24" }, light ? "Snooze 1 h" : "Snooze 1 d", "quiet")}</span>` : "";
154 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)} · ${light ? `${d.everyHours} h day` : `every ${d.everyHours} h`}</span></span>${acts}</li>`;
150155 })
151156 .join("");
152157
Software/so101/src/care-routes.ts +8 −4
@@ -5,7 +5,7 @@ import { boltingWatch, currentStage, health, reminders, snapshot, stageHistory,
55 import { LogError, type CareLog } from "./care-log.ts";
66 import { learnData, renderLearn } from "./care-sources.ts";
77 import { renderCare } from "./care-page.ts";
8 import { lastLight } from "./light-day.ts";
8 import { LIGHT_OFF, lastLight } from "./light-day.ts";
99 import { checkinEntry, renderCheckin } from "./care-checkin.ts";
1010 import { calibrateProbe, renderProbes, type ProbeCache } from "./care-probes.ts";
1111 import type { Profile } from "./care-profile.ts";
@@ -26,6 +26,8 @@ export interface CareContext {
2626 now?: () => number;
2727 /** The Pi's time zone, asked of the Pi when not yet known; null when it does not answer. Without it, UTC. */
2828 zone?: () => Promise<string | null>;
29 /** The light's scheduled hours as the Pi last said, null before it has; the light-off reminder's day length. */
30 lightHours?: () => number | null;
2931 /** The pH/EC probes on the Pi; without them the page has no probe panel. */
3032 probes?: ProbeCache;
3133 }
@@ -57,7 +59,7 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path
5759
5860 if (req.method === "GET" && pathname === "/care") {
5961 const probes = renderProbes(ctx.probes?.peek() ?? null, me.owner);
60 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) }));
62 send(200, "text/html; charset=utf-8", renderCare({ probes, profile: ctx.profile, log: ctx.log, lightHours: ctx.lightHours?.(), 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) }));
6163 return true;
6264 }
6365 if (req.method === "GET" && pathname === "/care/learn") {
@@ -91,7 +93,7 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path
9193 return true;
9294 }
9395 if (req.method === "GET" && pathname === "/care/api") {
94 const snap = snapshot(ctx.log);
96 const snap = snapshot(ctx.log, ctx.lightHours?.());
9597 const spans = stageHistory(ctx.profile, snap.entries, snap.startedAt);
9698 json(200, {
9799 profile: ctx.profile.id,
@@ -165,7 +167,9 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path
165167 result = done.view;
166168 } else if (reminder) {
167169 const rid = String(input.id ?? "");
168 if (input.action === "done") result = ctx.log.add({ type: "action", what: "done", ref: rid });
170 // Done with the light-off reminder is the light going off: logged as such, so the day closes.
171 if (input.action === "done") result = ctx.log.add(rid === LIGHT_OFF ? { type: "action", what: "lightOff", cause: "owner" } : { type: "action", what: "done", ref: rid });
172 else if (input.action === "every" && rid === LIGHT_OFF) throw new LogError("the light's day follows its schedule: change the hours on the light");
169173 else if (input.action === "snooze") ctx.log.snooze(rid, Number(input.hours ?? 24));
170174 else if (input.action === "every") ctx.log.setEvery(rid, input.hours === null ? null : Number(input.hours));
171175 else throw new LogError("action is done, snooze or every");
Software/so101/src/care-rules.ts +24 −2
@@ -5,6 +5,8 @@
55
66 import type { Band, Measure, Profile, Reminder, Stage } from "./care-profile.ts";
77 import { keyOf, type Entry, type CareLog, type Reading, type Transplant } from "./care-log.ts";
8 import { LIGHT_OFF, openDay } from "./light-day.ts";
9 import { DEFAULT_SCHEDULE } from "./light-panel.ts";
810
911 const HOUR = 3_600_000;
1012 const DAY = 24 * HOUR;
@@ -14,8 +16,10 @@ export interface Snapshot {
1416 startedAt: number;
1517 snoozes: Readonly<Record<string, number>>;
1618 every: Readonly<Record<string, number>>;
19 /** The light's scheduled hours as the Pi last said; the light-off reminder falls back to the default schedule without it. */
20 lightHours?: number;
1721 }
18 export const snapshot = (log: CareLog): Snapshot => ({ entries: log.entries(), startedAt: log.startedAt, snoozes: log.snoozes, every: log.every });
22 export const snapshot = (log: CareLog, lightHours?: number | null): Snapshot => ({ entries: log.entries(), startedAt: log.startedAt, snoozes: log.snoozes, every: log.every, ...(lightHours ? { lightHours } : {}) });
1923
2024 export interface StageSpan {
2125 stage: Stage;
@@ -59,11 +63,28 @@ export interface Due {
5963 snoozedUntil: number | null;
6064 }
6165
62 /** Each reminder of the current stage with when it falls due: the cadence after the last matching log entry, or after the stage began. */
66 /** While the light is logged on (#641): switch it off at the actual on-time plus the day's hours. A lightOff ends it; its cadence is the schedule's, never edited here. */
67 function lightOff(stage: Stage, snap: Snapshot, now: number): Due | null {
68 const day = openDay(snap.entries, now, snap.lightHours ?? DEFAULT_SCHEDULE.hours);
69 if (!day) return null;
70 const reminder: Reminder = {
71 id: LIGHT_OFF,
72 label: "Switch the grow light off",
73 why: `${day.hours} h of light from when it came on: a longer day pushes spinach to bolt.`,
74 sources: stage.sources.lightHours ?? [],
75 everyHours: day.hours,
76 resets: ["action:lightOff"],
77 };
78 const until = snap.snoozes[LIGHT_OFF];
79 return { reminder, everyHours: day.hours, dueAt: day.offDue, overdueMs: now - day.offDue, snoozedUntil: until && until > now ? until : null };
80 }
81
82 /** Each reminder of the current stage with when it falls due: the cadence after the last matching log entry, or after the stage began; and the light-off one while the light is on. */
6383 export function reminders(profile: Profile, snap: Snapshot, now: number): Due[] {
6484 const spans = stageHistory(profile, snap.entries, snap.startedAt);
6585 const { stage, from } = currentStage(spans);
6686 const moved = transplantOf(snap.entries)?.t;
87 const light = lightOff(stage, snap, now);
6788 return stage.reminders
6889 .filter((r) => !r.needs || (r.needs === "pump" ? moved?.pump : moved?.system === "vertical tower"))
6990 .map((reminder) => {
@@ -75,6 +96,7 @@ export function reminders(profile: Profile, snap: Snapshot, now: number): Due[]
7596 const until = snap.snoozes[reminder.id];
7697 return { reminder, everyHours, dueAt, overdueMs: now - dueAt, snoozedUntil: until && until > now ? until : null };
7798 })
99 .concat(light ? [light] : [])
78100 .sort((a, b) => a.dueAt - b.dueAt);
79101 }
80102
Software/so101/src/light-day.ts +12
@@ -50,6 +50,18 @@ export function lastLight(entries: readonly Entry[], now: number): "on" | "off"
5050 return e && e.type === "action" ? (e.what === "lightOn" ? "on" : "off") : null;
5151 }
5252
53 /** The care reminder that the light goes off (#641): its id in the reminders list, the snoozes and `/care/reminder`. */
54 export const LIGHT_OFF = "light-off";
55
56 /** The day still open: the latest lightOn by `now` with no lightOff after it, within a day and a half; null otherwise. Needs no zone. */
57 export function openDay(entries: readonly Entry[], now: number, scheduledHours: number): { onAt: number; offDue: number; hours: number } | null {
58 const on = latest(entries.filter((x) => x.type === "action" && x.what === "lightOn" && x.at <= now));
59 if (!on || now - on.at > STALE_MS) return null;
60 if (entries.some((x) => x.type === "action" && x.what === "lightOff" && x.at > on.at && x.at <= now)) return null;
61 const hours = dayHours(scheduledHours);
62 return { onAt: on.at, offDue: on.at + hours * HOUR, hours };
63 }
64
5365 /** The current (or just-finished) day from the log; null when the light was never logged on, or not within a day and a half. */
5466 export function lightDay(entries: readonly Entry[], now: number, zone: string, schedule: Photo): LightDay | null {
5567 const on = latest(entries.filter((x) => x.type === "action" && x.what === "lightOn" && x.at <= now));
Software/so101/src/light-off.test.ts added +101
@@ -0,0 +1,101 @@
1 import { describe, expect, test } from "bun:test";
2 import { createServer } from "node:http";
3 import { CareLog } from "./care-log.ts";
4 import { health, nextDue, overdue, reminders, snapshot } from "./care-rules.ts";
5 import { BUILT_IN_DIR, loadProfiles } from "./care-profile.ts";
6 import { renderCare } from "./care-page.ts";
7 import { handleCare } from "./care-routes.ts";
8 import { LIGHT_OFF } from "./light-day.ts";
9
10 const H = 3_600_000;
11 const DAY0 = Date.UTC(2026, 9, 27);
12 const spinach = loadProfiles(BUILT_IN_DIR).find((p) => p.id === "spinach")!;
13
14 function book() {
15 const clock = { now: DAY0 };
16 const log = new CareLog(undefined, () => clock.now);
17 const light = (what: "lightOn" | "lightOff", at: number) => ((clock.now = at), log.add({ type: "action", what, at }));
18 return { clock, log, light };
19 }
20 const off = (log: CareLog, now: number, hours?: number) => reminders(spinach, snapshot(log, hours), now).find((d) => d.reminder.id === LIGHT_OFF);
21
22 describe("the light-off reminder (#641)", () => {
23 test("is due at the actual on-time plus the schedule's hours, cited, and absent before any lightOn", () => {
24 const { log, light } = book();
25 expect(off(log, DAY0 + 9 * H)).toBeUndefined();
26 light("lightOn", DAY0 + 8.5 * H);
27 const d = off(log, DAY0 + 10 * H)!;
28 expect(d.dueAt).toBe(DAY0 + 20.5 * H);
29 expect(d.everyHours).toBe(12);
30 expect(d.reminder.sources.length).toBeGreaterThan(0);
31 expect(off(log, DAY0 + 10 * H, 13)!.dueAt).toBe(DAY0 + 21.5 * H);
32 // Held to the spinach maximum, whatever the Pi says.
33 expect(off(log, DAY0 + 10 * H, 18)!.dueAt).toBe(DAY0 + 22.5 * H);
34 });
35
36 test("a lightOff ends it, and the next lightOn starts a new one", () => {
37 const { log, light } = book();
38 light("lightOn", DAY0 + 8 * H);
39 light("lightOff", DAY0 + 20 * H);
40 expect(off(log, DAY0 + 21 * H)).toBeUndefined();
41 light("lightOn", DAY0 + 31 * H);
42 expect(off(log, DAY0 + 32 * H)!.dueAt).toBe(DAY0 + 43 * H);
43 });
44
45 test("overdue lands in the list the watchdog reads and on the health strip; a snooze hides it until the next lightOn", () => {
46 const { clock, log, light } = book();
47 light("lightOn", DAY0 + 8 * H);
48 const late = DAY0 + 22 * H;
49 expect(overdue(reminders(spinach, snapshot(log), late)).map((d) => d.reminder.id)).toContain(LIGHT_OFF);
50 expect(health(spinach, snapshot(log), late).lines.join()).toContain("Switch the grow light off is 2 h overdue");
51 clock.now = late;
52 log.snooze(LIGHT_OFF, 1);
53 expect(overdue(reminders(spinach, snapshot(log), late + 0.5 * H)).map((d) => d.reminder.id)).not.toContain(LIGHT_OFF);
54 expect(overdue(reminders(spinach, snapshot(log), late + 1.5 * H)).map((d) => d.reminder.id)).toContain(LIGHT_OFF);
55 log.snooze(LIGHT_OFF, 24);
56 light("lightOff", late + 2 * H);
57 light("lightOn", DAY0 + 32 * H);
58 expect(off(log, DAY0 + 45 * H)!.snoozedUntil).toBeNull();
59 });
60
61 test("is the grow card's next due while the light is on", () => {
62 const { log, light } = book();
63 light("lightOn", DAY0 + 8 * H);
64 expect(nextDue(spinach, snapshot(log), DAY0 + 19 * H)?.reminder.id).toBe(LIGHT_OFF);
65 });
66
67 test("the care page lists it with an hour's snooze and an It's off button", () => {
68 const { log, light } = book();
69 light("lightOn", DAY0 + 8 * H);
70 const html = renderCare({ profile: spinach, log, now: DAY0 + 9 * H, owner: true, signedIn: null });
71 const row = html.slice(html.indexOf(`data-reminder="${LIGHT_OFF}"`));
72 expect(row).toContain("Switch the grow light off");
73 expect(row.slice(0, row.indexOf("</li>"))).toContain("Snooze 1 h");
74 expect(row.slice(0, row.indexOf("</li>"))).toContain("It&#39;s off");
75 });
76
77 test("Done on it logs the lightOff, its cadence cannot be edited, and /care/api lists it", async () => {
78 const { clock, log, light } = book();
79 light("lightOn", Date.now() - 13 * H);
80 clock.now = Date.now();
81 const server = createServer((req, res) => {
82 const readBody = (r: typeof req) => new Promise<Buffer>((ok) => { const c: Buffer[] = []; r.on("data", (d) => c.push(d)); r.on("end", () => ok(Buffer.concat(c))); });
83 handleCare(req, res, new URL(req.url!, "http://x").pathname, { profile: spinach, log, readBody, lightHours: () => 12, who: () => ({ owner: true, known: true, signedIn: null }) }).then((hit) => hit || (res.writeHead(404), res.end()));
84 });
85 await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok));
86 const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
87 const post = (body: object) => fetch(`${base}/care/reminder`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
88 try {
89 const api = (await (await fetch(`${base}/care/api`)).json()) as { reminders: { id: string }[]; health: { ok: boolean } };
90 expect(api.reminders.map((r) => r.id)).toContain(LIGHT_OFF);
91 expect(api.health.ok).toBe(false);
92 expect((await post({ id: LIGHT_OFF, action: "every", hours: 16 })).status).toBe(400);
93 expect((await post({ id: LIGHT_OFF, action: "done" })).status).toBe(200);
94 expect(log.entries().at(-1)).toMatchObject({ type: "action", what: "lightOff", cause: "owner" });
95 const after = (await (await fetch(`${base}/care/api`)).json()) as { reminders: { id: string }[] };
96 expect(after.reminders.map((r) => r.id)).not.toContain(LIGHT_OFF);
97 } finally {
98 server.close();
99 }
100 });
101 });
Software/so101/src/relay.ts +3 −3
@@ -868,7 +868,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
868868 /** The grow card's first paint: the plant's day, the last watering and the next reminder, all from the care log, and the Pi's state. */
869869 const growNow = (): GrowNow => {
870870 const now = Date.now();
871 const snap = snapshot(care.log);
871 const snap = snapshot(care.log, lightSeen?.hours);
872872 const plant = growing(care.profile, snap, now);
873873 const next = nextDue(care.profile, snap, now);
874874 // Until the Pi has answered once its zone is unknown, and a light day worded in UTC would be wrong: say nothing and ask.
@@ -1239,7 +1239,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
12391239 pi,
12401240 stills: options.grow ? { latestAt: latest?.at ?? null, everyMs: options.grow.everyMs } : null,
12411241 light,
1242 overdue: overdue(reminders(care.profile, snapshot(care.log), now)).map((d) => ({ id: d.reminder.id, label: d.reminder.label, dueAt: d.dueAt })),
1242 overdue: overdue(reminders(care.profile, snapshot(care.log, lightSeen?.hours), now)).map((d) => ({ id: d.reminder.id, label: d.reminder.label, dueAt: d.dueAt })),
12431243 zone: grow?.light.zone || "UTC",
12441244 };
12451245 };
@@ -1300,7 +1300,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
13001300 return { owner: Boolean(person?.owner), known: Boolean(person), id: person?.id, signedIn: lobby && !lobby.local ? lobby.signedIn(r) : null };
13011301 };
13021302 if (await handleDosing(req, res, url.pathname, { doser, log: care.log, who, readBody, sameOrigin, pumps: pumpHooks })) return;
1303 if (await handleCare(req, res, url.pathname, { profile: care.profile, log: care.log, probes: probeCache, doser, pumps: () => pumpRead.read(), who, readBody, zone: async () => (lightSeen ?? ((await lightHooks.read()) && lightSeen))?.zone ?? null })) return;
1303 if (await handleCare(req, res, url.pathname, { profile: care.profile, log: care.log, probes: probeCache, doser, pumps: () => pumpRead.read(), who, readBody, lightHours: () => lightSeen?.hours ?? null, zone: async () => (lightSeen ?? ((await lightHooks.read()) && lightSeen))?.zone ?? null })) return;
13041304 }
13051305 if (reading && url.pathname === "/stations") return json(res, 200, stationViews().map(({ station, present, resting, cameras: c }) => ({ id: station.id, name: station.name, kind: station.kind, present, ...(resting ? { resting } : {}), cameras: c })));
13061306 const cameraAdmin = ADMIN_CAMERA.exec(url.pathname);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs