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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/deal-clock:mason/157-the-sample-deals-finished-dates-read-don → main 1 commit fork

  • Merged. mason/157-the-sample-deals-finished-dates-read-don was merged into main as 36112c0.

Review

Sign in to review this change.
2 files changed +53 −4
src/sample.test.ts +37 −1
@@ -6,7 +6,8 @@ import { openDb } from "./db.ts";
66 import { listDeals } from "./deals.ts";
77 import { drafts, unsent } from "./reminders.ts";
88 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";
1011
1112 const dates: SampleDates = {
1213 contractDate: "2026-09-01",
@@ -39,6 +40,7 @@ test("a sample a person changed is not untouched", () => {
3940 customs: 1,
4041 reminders: 0,
4142 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"],
4244 waivedAts: [null, null, null, null],
4345 moveAuthors: ["Sample data"],
4446 };
@@ -59,3 +61,37 @@ test("the sample deals hold an overdue date with its follow-up waiting", () => {
5961 const late = listed.value.flatMap((d) => drafts(d, agent)).filter((r) => r.moment === "late" && unsent(r));
6062 expect(late.length).toBeGreaterThan(0);
6163 });
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[] = [
129129 /** The time of day a sample's finished dates carry: what tells them from a person's click. */
130130 const DONE_TIME = "T17:00:00.000Z";
131131
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
132138 /** The dates of one sample deal, as plain data. */
133139 export interface SampleDates {
134140 readonly contractDate: string;
@@ -165,6 +171,8 @@ export interface SampleState {
165171 readonly customs: number;
166172 readonly reminders: number;
167173 readonly doneAts: readonly (string | null)[];
174 /** The due day of each date in `doneAts`, in the same order. */
175 readonly dues: readonly string[];
168176 readonly waivedAts: readonly (string | null)[];
169177 readonly moveAuthors: readonly string[];
170178 }
@@ -174,11 +182,12 @@ export function isUntouched(state: SampleState, anchor: string): boolean {
174182 const s = SAMPLES.find((x) => x.address === state.sample);
175183 if (s === undefined) return false;
176184 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;
177186 return (
178187 state.parties === s.parties.length &&
179188 state.customs === (s.custom?.length ?? 0) &&
180189 state.reminders === 0 &&
181 state.doneAts.every((d) => d === null || d === marker) &&
190 state.doneAts.every(stamped) &&
182191 state.doneAts.filter((d) => d !== null).length === (s.done?.length ?? 0) &&
183192 state.waivedAts.every((d) => d === null || d === marker) &&
184193 state.waivedAts.filter((d) => d !== null).length === (s.waived?.length ?? 0) &&
@@ -220,6 +229,7 @@ export function refresh(db: Database, on = today()): void {
220229 customs: deadlines.filter((d) => d.kind === "custom").length,
221230 reminders,
222231 doneAts: deadlines.map((d) => d.done_at),
232 dues: deadlines.map((d) => d.due),
223233 waivedAts: deadlines.map((d) => d.waived_at),
224234 moveAuthors: moves.map((m) => m.by_name),
225235 };
@@ -233,8 +243,11 @@ export function refresh(db: Database, on = today()): void {
233243 anchor,
234244 on,
235245 );
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));
236249 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);
238251 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);
239252 // The sample's sent reminder follows its date, so it stays "yesterday's".
240253 const days = `+${daysBetween(anchor, on)} days`;
@@ -270,7 +283,7 @@ function writeSamples(db: Database, on: string): void {
270283 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);
271284 }
272285 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);
274287 for (const kind of s.waived ?? [])
275288 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);
276289 for (const [kind, note] of s.notes ?? [])

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs