Public: anyone can read this; the crew can change it.
The sample deals' finished dates read "Done on time" or "early", not "22 days late", so the demo shows an agent who keeps their deals on track #160
Check · Review · Land · Live done — Landed and live in production.
mason/deal-clock:mason/157-the-sample-deals-finished-dates-read-don → main 1 commit fork
2 files
changed +53
−4
src/sample.test.ts +37 −1
| @@ -6,7 +6,8 @@ import { openDb } from "./db.ts"; | ||
| 6 | 6 | import { listDeals } from "./deals.ts"; |
| 7 | 7 | import { drafts, unsent } from "./reminders.ts"; |
| 8 | 8 | import type { Who } from "./who.ts"; |
| 9 | import { isUntouched, shiftSample, type SampleDates } from "./sample.ts"; | |
| 9 | import { addDays } from "./dates.ts"; | |
| 10 | import { isUntouched, refresh, restore, shiftSample, type SampleDates } from "./sample.ts"; | |
| 10 | 11 | |
| 11 | 12 | const dates: SampleDates = { |
| 12 | 13 | contractDate: "2026-09-01", |
| @@ -39,6 +40,7 @@ test("a sample a person changed is not untouched", () => { | ||
| 39 | 40 | customs: 1, |
| 40 | 41 | reminders: 0, |
| 41 | 42 | doneAts: ["2026-09-30T17:00:00.000Z", "2026-09-30T17:00:00.000Z", "2026-09-30T17:00:00.000Z", null], |
| 43 | dues: ["2026-10-01", "2026-10-01", "2026-10-01", "2026-10-09"], | |
| 42 | 44 | waivedAts: [null, null, null, null], |
| 43 | 45 | moveAuthors: ["Sample data"], |
| 44 | 46 | }; |
| @@ -59,3 +61,37 @@ test("the sample deals hold an overdue date with its follow-up waiting", () => { | ||
| 59 | 61 | const late = listed.value.flatMap((d) => drafts(d, agent)).filter((r) => r.moment === "late" && unsent(r)); |
| 60 | 62 | expect(late.length).toBeGreaterThan(0); |
| 61 | 63 | }); |
| 64 | ||
| 65 | function lateDone(db: ReturnType<typeof openDb>, on = new Date().toISOString().slice(0, 10)): string[] { | |
| 66 | return db | |
| 67 | .query<{ kind: string; due: string; done_at: string }, []>("SELECT kind, due, done_at FROM deadline WHERE done_at IS NOT NULL AND deal_id IN (SELECT id FROM deal WHERE author_id = 'sample')") | |
| 68 | .all() | |
| 69 | .filter((d) => d.done_at.slice(0, 10) > d.due || d.done_at.slice(0, 10) >= on) | |
| 70 | .map((d) => `${d.kind} due ${d.due} done ${d.done_at}`); | |
| 71 | } | |
| 72 | ||
| 73 | test("no finished sample date reads late: after seed, after a refresh, after restore, and on a database seeded before", () => { | |
| 74 | const db = openDb(mkdtempSync(join(tmpdir(), "app-sample-ontime-"))); | |
| 75 | const today = new Date().toISOString().slice(0, 10); | |
| 76 | expect(lateDone(db)).toEqual([]); | |
| 77 | refresh(db, addDays(today, 6)); | |
| 78 | expect(lateDone(db, addDays(today, 6))).toEqual([]); | |
| 79 | restore(db, addDays(today, 6)); | |
| 80 | expect(lateDone(db, addDays(today, 6))).toEqual([]); | |
| 81 | ||
| 82 | // Seeded before the fix: every finished date stamped the day before the anchor. | |
| 83 | const anchor = db.query<{ value: string }, []>("SELECT value FROM app_meta WHERE key = 'sample_anchor'").get()!.value; | |
| 84 | db.query("UPDATE deadline SET done_at = ?1 WHERE done_at IS NOT NULL AND deal_id IN (SELECT id FROM deal WHERE author_id = 'sample')").run(`${addDays(anchor, -1)}T17:00:00.000Z`); | |
| 85 | refresh(db, addDays(anchor, 3)); | |
| 86 | expect(lateDone(db, addDays(anchor, 3))).toEqual([]); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a real deal's finished date is left alone by a refresh", () => { | |
| 90 | const db = openDb(mkdtempSync(join(tmpdir(), "app-sample-real-"))); | |
| 91 | const real = db.query<{ id: number }, []>("SELECT id FROM deal WHERE author_id = 'sample' LIMIT 1").get()!.id; | |
| 92 | const stamp = "2026-01-01T09:30:00.000Z"; | |
| 93 | db.query("UPDATE deadline SET done_at = ?1 WHERE deal_id = ?2 AND done_at IS NOT NULL").run(stamp, real); | |
| 94 | refresh(db, addDays(new Date().toISOString().slice(0, 10), 5)); | |
| 95 | const kept = db.query<{ done_at: string }, [number]>("SELECT done_at FROM deadline WHERE deal_id = ?1 AND done_at IS NOT NULL").all(real); | |
| 96 | expect(kept.every((d) => d.done_at === stamp)).toBe(true); | |
| 97 | }); |
src/sample.ts +16 −3
| @@ -129,6 +129,12 @@ const SAMPLES: readonly Sample[] = [ | ||
| 129 | 129 | /** The time of day a sample's finished dates carry: what tells them from a person's click. */ |
| 130 | 130 | const DONE_TIME = "T17:00:00.000Z"; |
| 131 | 131 | |
| 132 | /** A sample date finished a day before it was due, or yesterday if it is not due yet: on time, never late, never in the future. */ | |
| 133 | function doneStamp(due: string, on: string): string { | |
| 134 | const day = addDays(due, -1); | |
| 135 | return `${day < on ? day : addDays(on, -1)}${DONE_TIME}`; | |
| 136 | } | |
| 137 | ||
| 132 | 138 | /** The dates of one sample deal, as plain data. */ |
| 133 | 139 | export interface SampleDates { |
| 134 | 140 | readonly contractDate: string; |
| @@ -165,6 +171,8 @@ export interface SampleState { | ||
| 165 | 171 | readonly customs: number; |
| 166 | 172 | readonly reminders: number; |
| 167 | 173 | readonly doneAts: readonly (string | null)[]; |
| 174 | /** The due day of each date in `doneAts`, in the same order. */ | |
| 175 | readonly dues: readonly string[]; | |
| 168 | 176 | readonly waivedAts: readonly (string | null)[]; |
| 169 | 177 | readonly moveAuthors: readonly string[]; |
| 170 | 178 | } |
| @@ -174,11 +182,12 @@ export function isUntouched(state: SampleState, anchor: string): boolean { | ||
| 174 | 182 | const s = SAMPLES.find((x) => x.address === state.sample); |
| 175 | 183 | if (s === undefined) return false; |
| 176 | 184 | const marker = `${addDays(anchor, -1)}${DONE_TIME}`; |
| 185 | const stamped = (d: string | null, i: number) => d === null || (d.endsWith(DONE_TIME) && d.slice(0, 10) < state.dues[i]!) || d === marker; | |
| 177 | 186 | return ( |
| 178 | 187 | state.parties === s.parties.length && |
| 179 | 188 | state.customs === (s.custom?.length ?? 0) && |
| 180 | 189 | state.reminders === 0 && |
| 181 | state.doneAts.every((d) => d === null || d === marker) && | |
| 190 | state.doneAts.every(stamped) && | |
| 182 | 191 | state.doneAts.filter((d) => d !== null).length === (s.done?.length ?? 0) && |
| 183 | 192 | state.waivedAts.every((d) => d === null || d === marker) && |
| 184 | 193 | state.waivedAts.filter((d) => d !== null).length === (s.waived?.length ?? 0) && |
| @@ -220,6 +229,7 @@ export function refresh(db: Database, on = today()): void { | ||
| 220 | 229 | customs: deadlines.filter((d) => d.kind === "custom").length, |
| 221 | 230 | reminders, |
| 222 | 231 | doneAts: deadlines.map((d) => d.done_at), |
| 232 | dues: deadlines.map((d) => d.due), | |
| 223 | 233 | waivedAts: deadlines.map((d) => d.waived_at), |
| 224 | 234 | moveAuthors: moves.map((m) => m.by_name), |
| 225 | 235 | }; |
| @@ -233,8 +243,11 @@ export function refresh(db: Database, on = today()): void { | ||
| 233 | 243 | anchor, |
| 234 | 244 | on, |
| 235 | 245 | ); |
| 246 | // A database seeded before the fix stamped every finished date the day before the anchor: restamp it against its own due day. | |
| 247 | const legacy = `${addDays(anchor, -1)}${DONE_TIME}`; | |
| 248 | const restamped = shifted.deadlines.map((d, i) => (deadlines[i]!.done_at === legacy ? { ...d, doneAt: doneStamp(d.due, on) } : d)); | |
| 236 | 249 | db.query("UPDATE deal SET contract_date = ?1 WHERE id = ?2").run(shifted.contractDate, deal.id); |
| 237 | for (const d of shifted.deadlines) db.query("UPDATE deadline SET due = ?1, done_at = ?2, waived_at = ?3 WHERE id = ?4").run(d.due, d.doneAt, d.waivedAt, d.id); | |
| 250 | for (const d of restamped) db.query("UPDATE deadline SET due = ?1, done_at = ?2, waived_at = ?3 WHERE id = ?4").run(d.due, d.doneAt, d.waivedAt, d.id); | |
| 238 | 251 | for (const m of shifted.moves) db.query("UPDATE deadline_move SET from_due = ?1, to_due = ?2 WHERE id = ?3").run(m.fromDue, m.toDue, m.id); |
| 239 | 252 | // The sample's sent reminder follows its date, so it stays "yesterday's". |
| 240 | 253 | const days = `+${daysBetween(anchor, on)} days`; |
| @@ -270,7 +283,7 @@ function writeSamples(db: Database, on: string): void { | ||
| 270 | 283 | db.query("INSERT INTO party (deal_id, role, name, contact, email, phone) VALUES (?1, ?2, ?3, ?4, ?5, ?6)").run(deal.id, role, name, email || phone, email, phone); |
| 271 | 284 | } |
| 272 | 285 | for (const kind of s.done ?? []) |
| 273 | db.query("UPDATE deadline SET done_at = ?1 WHERE deal_id = ?2 AND kind = ?3").run(`${addDays(on, -1)}T17:00:00.000Z`, deal.id, kind); | |
| 286 | db.query("UPDATE deadline SET done_at = min(date(due, '-1 day'), ?1) || ?2 WHERE deal_id = ?3 AND kind = ?4").run(addDays(on, -1), DONE_TIME, deal.id, kind); | |
| 274 | 287 | for (const kind of s.waived ?? []) |
| 275 | 288 | db.query("UPDATE deadline SET waived_at = ?1, waived_by = ?2 WHERE deal_id = ?3 AND kind = ?4").run(`${addDays(on, -1)}${DONE_TIME}`, SAMPLE.name, deal.id, kind); |
| 276 | 289 | for (const [kind, note] of s.notes ?? []) |
new above
offline