Public: anyone can read this; the crew can change it.
week.ts can say how many Next up rows this week's pace reaches, as plain data with no read #12145
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12133-week-ts-can-say-how-many-next-up-rows-th → main 3 commits fork
3 files
changed +88
−0
scripts/unused-exports-allow.json +2
| @@ -87,6 +87,8 @@ | ||
| 87 | 87 | "src/flow/kpi_words.ts trafficWords", |
| 88 | 88 | "src/flow/reach.ts NO_REACH", |
| 89 | 89 | "src/flow/shared_red.ts sharedRedSays", |
| 90 | "src/flow/week.ts daysLeftInWeek", | |
| 91 | "src/flow/week.ts paceCut", | |
| 90 | 92 | "src/git/coauthors.ts creditsOf", |
| 91 | 93 | "src/git/diff-memory.ts diffMemoryCounts", |
| 92 | 94 | "src/git/diff-memory.ts forgetDiffs", |
src/flow/week.ts +40
| @@ -145,3 +145,43 @@ export function weekView(db: Database, repoId: string, rows: readonly NextRow[], | ||
| 145 | 145 | const cut = cutLine(priced, capacity); |
| 146 | 146 | return { priced, cut, capacity, sentence: projection(cut, rows.length, capacity) }; |
| 147 | 147 | } |
| 148 | ||
| 149 | const DAY_MS = 86_400_000; | |
| 150 | ||
| 151 | /** | |
| 152 | * Days from `now` to `resetsAt` when it is a valid instant, else to the end of the ISO week | |
| 153 | * (Monday 00:00 UTC); never negative. | |
| 154 | */ | |
| 155 | export function daysLeftInWeek(now: Date, resetsAt?: Date | string | null): number { | |
| 156 | const at = resetsAt == null ? null : new Date(resetsAt); | |
| 157 | let end: number; | |
| 158 | if (at !== null && Number.isFinite(at.getTime())) end = at.getTime(); | |
| 159 | else { | |
| 160 | const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); | |
| 161 | const sinceMonday = (now.getUTCDay() + 6) % 7; | |
| 162 | end = midnight + (7 - sinceMonday) * DAY_MS; | |
| 163 | } | |
| 164 | return Math.max(0, (end - now.getTime()) / DAY_MS); | |
| 165 | } | |
| 166 | ||
| 167 | export interface PaceCut { | |
| 168 | readonly rows: number; | |
| 169 | readonly reason: string; | |
| 170 | } | |
| 171 | ||
| 172 | const num = (n: number): string => String(Math.round(n * 10) / 10); | |
| 173 | const plural = (n: number, one: string, many: string): string => `${num(n)} ${n === 1 ? one : many}`; | |
| 174 | ||
| 175 | /** How many Next up rows this week's pace reaches, in plain data: no read, no page. */ | |
| 176 | export function paceCut(p: { landedPerDay: number; daysLeft: number; total: number; landed: number }): PaceCut { | |
| 177 | const pace = Number.isFinite(p.landedPerDay) ? Math.max(0, p.landedPerDay) : 0; | |
| 178 | const days = Number.isFinite(p.daysLeft) ? Math.max(0, p.daysLeft) : 0; | |
| 179 | const total = Math.max(0, Math.floor(p.total) || 0); | |
| 180 | const rows = Math.min(total, Math.max(0, Math.floor(pace * days))); | |
| 181 | const guess = p.landed < 3 ? " (a guess: fewer than 3 landed)" : ""; | |
| 182 | const reason = | |
| 183 | pace === 0 | |
| 184 | ? `by pace: nothing has landed lately, so none reach this week${guess}` | |
| 185 | : `by pace: ${plural(pace, "issue", "issues")} a day, ${plural(days, "day", "days")} left${guess}`; | |
| 186 | return { rows, reason }; | |
| 187 | } |
test/week-pace.test.ts added +46
| @@ -0,0 +1,46 @@ | ||
| 1 | /** How far this week's pace reaches, as plain data (#12133). */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { daysLeftInWeek, paceCut } from "../src/flow/week.ts"; | |
| 4 | ||
| 5 | const cut = (landedPerDay: number, over: Partial<Parameters<typeof paceCut>[0]> = {}) => | |
| 6 | paceCut({ landedPerDay, daysLeft: 3, total: 10, landed: 5, ...over }); | |
| 7 | ||
| 8 | describe("paceCut", () => { | |
| 9 | test("zero pace reaches zero rows and says so", () => { | |
| 10 | const c = cut(0); | |
| 11 | expect(c.rows).toBe(0); | |
| 12 | expect(c.reason).toContain("nothing has landed"); | |
| 13 | }); | |
| 14 | test("a faster pace moves the line down", () => { | |
| 15 | expect(cut(1).rows).toBe(3); | |
| 16 | expect(cut(2).rows).toBe(6); | |
| 17 | expect(cut(2).reason).toBe("by pace: 2 issues a day, 3 days left"); | |
| 18 | }); | |
| 19 | test("the line never passes total, nor goes below zero", () => { | |
| 20 | expect(cut(100).rows).toBe(10); | |
| 21 | expect(cut(2, { total: 0 }).rows).toBe(0); | |
| 22 | expect(cut(-1).rows).toBe(0); | |
| 23 | expect(cut(2, { daysLeft: -2 }).rows).toBe(0); | |
| 24 | }); | |
| 25 | test("fewer than 3 landed says the pace is a guess", () => { | |
| 26 | expect(cut(2, { landed: 2 }).reason).toContain("guess"); | |
| 27 | expect(cut(2, { landed: 3 }).reason).not.toContain("guess"); | |
| 28 | }); | |
| 29 | }); | |
| 30 | ||
| 31 | describe("daysLeftInWeek", () => { | |
| 32 | const now = new Date("2026-11-04T12:00:00Z"); // Wednesday | |
| 33 | test("days to resetsAt when given", () => { | |
| 34 | expect(daysLeftInWeek(now, "2026-11-06T12:00:00Z")).toBe(2); | |
| 35 | expect(daysLeftInWeek(now, new Date("2026-11-05T00:00:00Z"))).toBe(0.5); | |
| 36 | }); | |
| 37 | test("else to the end of the ISO week, Monday 00:00 UTC", () => { | |
| 38 | expect(daysLeftInWeek(now)).toBe(4.5); | |
| 39 | expect(daysLeftInWeek(now, null)).toBe(4.5); | |
| 40 | expect(daysLeftInWeek(new Date("2026-11-08T23:00:00Z"))).toBeCloseTo(1 / 24, 6); // Sunday | |
| 41 | expect(daysLeftInWeek(new Date("2026-11-02T00:00:00Z"))).toBe(7); // Monday | |
| 42 | }); | |
| 43 | test("never negative", () => { | |
| 44 | expect(daysLeftInWeek(now, "2026-11-01T00:00:00Z")).toBe(0); | |
| 45 | }); | |
| 46 | }); |
new above
offline