@pithy-sh/testers 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/docs/store-apis.md +107 -0
- package/package.json +62 -0
- package/pithy.manifest.json +52 -0
- package/src/activity/resolve.ts +273 -0
- package/src/audit/actions.ts +56 -0
- package/src/capability.ts +128 -0
- package/src/clock/days.ts +70 -0
- package/src/clock/replay.ts +190 -0
- package/src/cloudflare-test.d.ts +13 -0
- package/src/config/config.ts +518 -0
- package/src/crypto/token.ts +60 -0
- package/src/data/cohort.ts +83 -0
- package/src/data/enums.ts +134 -0
- package/src/data/event.ts +81 -0
- package/src/data/member.ts +79 -0
- package/src/data/snapshot.ts +280 -0
- package/src/data/tables.ts +49 -0
- package/src/error/errors.ts +229 -0
- package/src/health/score.ts +225 -0
- package/src/http/guards.ts +37 -0
- package/src/http/pages.ts +66 -0
- package/src/http/responses.ts +634 -0
- package/src/http/routes.ts +933 -0
- package/src/http/schemas.ts +210 -0
- package/src/http/scopes.ts +79 -0
- package/src/http/view.ts +304 -0
- package/src/index.ts +80 -0
- package/src/migrations/0001_cohorts.ts +202 -0
- package/src/nudge/cooldown.ts +104 -0
- package/src/nudge/copy.ts +179 -0
- package/src/nudge/enqueueSeam.ts +95 -0
- package/src/nudge/send.ts +89 -0
- package/src/projection/build.ts +285 -0
- package/src/projection/forecast.ts +348 -0
- package/src/projection/inputs.ts +63 -0
- package/src/projection/poissonBinomial.ts +91 -0
- package/src/projection/trend.ts +185 -0
- package/src/provision/provisionTesters.ts +109 -0
- package/src/provision/resolveTestersConfig.ts +155 -0
- package/src/roster/read.ts +227 -0
- package/src/roster/write.ts +511 -0
- package/src/seeds/example.ts +219 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/daily.ts +513 -0
- package/src/workflows/pass.ts +100 -0
- package/src/workflows/report.ts +52 -0
- package/src/workflows/retryPolicy.ts +48 -0
- package/src/workflows/specs.ts +73 -0
- package/src/workflows/worker.ts +132 -0
- package/src/workflows/wrangler.jsonc +66 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { BindingSpecInput } from "@pithy-sh/core/src/capability/bindings";
|
|
5
|
+
import { type Capability, defineCapability } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import type { DatabaseSpecMap } from "@pithy-sh/core/src/data/databases";
|
|
7
|
+
import { workflowBindings } from "@pithy-sh/core/src/workflow/bindings";
|
|
8
|
+
import type { EmailCapability } from "@pithy-sh/email/src/capability";
|
|
9
|
+
import { isEmailCapability } from "@pithy-sh/email/src/capability";
|
|
10
|
+
import type { Migration } from "kysely/migration";
|
|
11
|
+
import { TestersConfig, type TestersConfigInput } from "./config/config";
|
|
12
|
+
import { testersTables } from "./data/tables";
|
|
13
|
+
import { registerTestersRoutes } from "./http/routes";
|
|
14
|
+
import { testersAdminRoutes } from "./http/scopes";
|
|
15
|
+
import { testers_0001_cohorts } from "./migrations/0001_cohorts";
|
|
16
|
+
import type { EnqueueNudge } from "./nudge/send";
|
|
17
|
+
import { testersExampleSeed } from "./seeds/example";
|
|
18
|
+
import { PACKAGE_VERSION } from "./version.generated";
|
|
19
|
+
import { testersWorkflows } from "./workflows/specs";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The testers capability: the roster, the invitations, and the fourteen-day clock.
|
|
23
|
+
*
|
|
24
|
+
* Composes into a Worker like any other capability. Its one hard dependency is `email` — an invitation
|
|
25
|
+
* that cannot be sent is not a testing program. `@pithy-sh/auth` is an **optional peer**: with it, testers who sign in
|
|
26
|
+
* become observable and the early-warning signal works; without it every tester resolves as
|
|
27
|
+
* unobservable, the forecast widens its band to say so, and everything else still runs.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Where testers' migrations sort in the app database. Unique per database; the registry composes keys
|
|
32
|
+
* like `1200_testers_0001_cohorts`. Sits after the control-plane seam (1100), and was `NEXT_FREE_ORDER`
|
|
33
|
+
* when it was taken — see `packages/cli/src/migrations/orders.test.ts`, the only place an order is
|
|
34
|
+
* allocated.
|
|
35
|
+
*/
|
|
36
|
+
export const TESTERS_MIGRATION_ORDER = 1300;
|
|
37
|
+
|
|
38
|
+
/** The database name every capability sharing the app D1 coordinates on. `DB` is the binding. */
|
|
39
|
+
const TESTERS_DATABASE_NAME = "app" as const;
|
|
40
|
+
|
|
41
|
+
/** The options `testers()` takes: the config's input side. */
|
|
42
|
+
export type TestersOptions = TestersConfigInput;
|
|
43
|
+
|
|
44
|
+
/** The testers capability, with its resolved config attached for tooling to read. */
|
|
45
|
+
export interface TestersCapability
|
|
46
|
+
extends Capability<DatabaseSpecMap, Record<never, never>, "testers", typeof testersWorkflows> {
|
|
47
|
+
/** The resolved configuration. */
|
|
48
|
+
testersConfig: TestersConfig;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether a composed capability is the testers capability — carries its resolved config. */
|
|
52
|
+
export function isTestersCapability(capability: Capability): capability is TestersCapability {
|
|
53
|
+
return capability.name === "testers" && "testersConfig" in capability;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Compose the testers capability. */
|
|
57
|
+
export function testers(options: TestersOptions = {}): TestersCapability {
|
|
58
|
+
// Parsed at assembly rather than lazily: a target larger than the roster cap, or an opt-in link that
|
|
59
|
+
// expires before the window it belongs to closes, fails on deploy instead of on the day a tester
|
|
60
|
+
// clicks a link that no longer works.
|
|
61
|
+
const resolved = TestersConfig.parse(options);
|
|
62
|
+
|
|
63
|
+
const migrations: Record<string, Migration> = { "0001_cohorts": testers_0001_cohorts };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The email seam, filled by `compose`.
|
|
67
|
+
*
|
|
68
|
+
* Held in a mutable slot rather than passed in because `compose` runs after the factory: the routes
|
|
69
|
+
* are registered at assembly and need a way to reach an enqueue that does not exist yet.
|
|
70
|
+
*/
|
|
71
|
+
const wiring: { enqueue: EmailCapability["enqueue"] | undefined } = { enqueue: undefined };
|
|
72
|
+
|
|
73
|
+
const requiredBindings: BindingSpecInput[] = [
|
|
74
|
+
// The app database — all four pithy_testers_* tables live here, alongside the pithy_auth_* tables
|
|
75
|
+
// the activity reader joins against.
|
|
76
|
+
{ type: "d1", name: "DB" },
|
|
77
|
+
// The daily pass's Workflow binding, derived from the spec rather than written again, so a binding
|
|
78
|
+
// rename cannot leave the two disagreeing. Optional because the binding exists only once
|
|
79
|
+
// `pithy testers provision` has deployed the host, and an unprovisioned project must still be able
|
|
80
|
+
// to invite testers, accept confirmations, and read its cohorts.
|
|
81
|
+
...workflowBindings(testersWorkflows),
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const capability = defineCapability({
|
|
85
|
+
name: "testers",
|
|
86
|
+
// The package version this capability ships at, stamped by `scripts/stampVersions.ts` — a Worker
|
|
87
|
+
// cannot read its own package.json. Reported per capability by the control-plane manifest.
|
|
88
|
+
version: PACKAGE_VERSION,
|
|
89
|
+
// Email is the one hard dependency: a capability whose whole job is inviting people cannot ship
|
|
90
|
+
// with "and you supply the delivery". Secrets is deliberately NOT one — the confirmation token is a
|
|
91
|
+
// random value on the member row rather than a signature, so nothing here reads a secret, and
|
|
92
|
+
// `pithy testers invite` can build a working invitation against any environment.
|
|
93
|
+
dependsOn: ["email"],
|
|
94
|
+
config: TestersConfig,
|
|
95
|
+
workflows: testersWorkflows,
|
|
96
|
+
compose: ({ capabilities }) => {
|
|
97
|
+
const email = capabilities.find(isEmailCapability);
|
|
98
|
+
// `dependsOn` already fails assembly without it; this narrows the type and gives a message
|
|
99
|
+
// naming what testers actually wanted it for.
|
|
100
|
+
if (email) wiring.enqueue = email.enqueue;
|
|
101
|
+
},
|
|
102
|
+
requiredBindings,
|
|
103
|
+
databases: {
|
|
104
|
+
[TESTERS_DATABASE_NAME]: {
|
|
105
|
+
binding: "DB",
|
|
106
|
+
tables: testersTables(),
|
|
107
|
+
migrationOrder: TESTERS_MIGRATION_ORDER,
|
|
108
|
+
migrations,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
// Built from the resolved `basePath`, never the default: an adopter who mounts this at `/beta` gets
|
|
112
|
+
// a manifest naming their paths, where a client assuming the default would 404.
|
|
113
|
+
adminRoutes: testersAdminRoutes(resolved.basePath),
|
|
114
|
+
routes: registerTestersRoutes({
|
|
115
|
+
config: resolved,
|
|
116
|
+
enqueue: (env) => {
|
|
117
|
+
const enqueue = wiring.enqueue;
|
|
118
|
+
if (!enqueue) return undefined;
|
|
119
|
+
// The email capability owns the `DB` and `EMAIL_SENDER` bindings and the from-identity; testers
|
|
120
|
+
// passes only the request env and the high-level input, and never names an email binding itself.
|
|
121
|
+
return ((input) => enqueue(env as never, input)) as EnqueueNudge;
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
seeds: [testersExampleSeed],
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return Object.assign(capability, { testersConfig: resolved });
|
|
128
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* UTC day keys — the `YYYY-MM-DD` strings the clock and the snapshot series are indexed by.
|
|
6
|
+
*
|
|
7
|
+
* **Why a string day rather than a timestamp.** Google's continuous-days counter runs on a day
|
|
8
|
+
* boundary that is neither documented nor readable, so Pithy picks one, uses it everywhere, and says
|
|
9
|
+
* which one it picked. UTC is the choice. Storing the day as text makes that choice total: the unique
|
|
10
|
+
* index on `(cohortId, snapshotOn)` is exact, day arithmetic never picks up an hour of drift from a
|
|
11
|
+
* server's locale, and two snapshots can never disagree about which day they describe because there is
|
|
12
|
+
* no timezone left in the value to disagree about.
|
|
13
|
+
*
|
|
14
|
+
* The consequence a developer must know, and the docs say so plainly: Pithy's day fourteen may not be
|
|
15
|
+
* Google's day fourteen. Which is one more reason not to start trusting the estimate on day thirteen.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Milliseconds in one day. */
|
|
19
|
+
const MS_PER_DAY = 86_400_000;
|
|
20
|
+
|
|
21
|
+
/** A UTC day key: `YYYY-MM-DD`. */
|
|
22
|
+
export type DayKey = string;
|
|
23
|
+
|
|
24
|
+
/** The UTC day a moment falls in. */
|
|
25
|
+
export function dayKey(at: Date): DayKey {
|
|
26
|
+
return at.toISOString().slice(0, 10);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Midnight UTC at the start of a day key — the instant the day begins. */
|
|
30
|
+
export function startOfDay(day: DayKey): Date {
|
|
31
|
+
return new Date(`${day}T00:00:00.000Z`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The last instant of a day key, inclusive. Everything at or before this happened on or before `day`. */
|
|
35
|
+
export function endOfDay(day: DayKey): Date {
|
|
36
|
+
return new Date(startOfDay(day).getTime() + MS_PER_DAY - 1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The day key `count` days after `day`. Negative counts go backwards. */
|
|
40
|
+
export function addDays(day: DayKey, count: number): DayKey {
|
|
41
|
+
return dayKey(new Date(startOfDay(day).getTime() + count * MS_PER_DAY));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whole days from `from` to `to`, signed. Exact rather than approximate, because both ends are
|
|
46
|
+
* midnight UTC by construction — no DST, no leap-second rounding, no off-by-one on the last day of a
|
|
47
|
+
* cohort.
|
|
48
|
+
*/
|
|
49
|
+
export function daysBetween(from: DayKey, to: DayKey): number {
|
|
50
|
+
return Math.round((startOfDay(to).getTime() - startOfDay(from).getTime()) / MS_PER_DAY);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every day key from `from` to `to`, inclusive, ascending. Empty when `to` precedes `from`. */
|
|
54
|
+
export function dayRange(from: DayKey, to: DayKey): DayKey[] {
|
|
55
|
+
const span = daysBetween(from, to);
|
|
56
|
+
if (span < 0) return [];
|
|
57
|
+
const days: DayKey[] = [];
|
|
58
|
+
for (let offset = 0; offset <= span; offset++) days.push(addDays(from, offset));
|
|
59
|
+
return days;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whole days a moment is in the past, relative to `now`, floored at zero.
|
|
64
|
+
*
|
|
65
|
+
* Floored deliberately: a clock skew that puts a session's timestamp a few seconds in the future should
|
|
66
|
+
* read as "seen just now", not as negative days dark, which would score a tester as impossibly healthy.
|
|
67
|
+
*/
|
|
68
|
+
export function daysSince(at: Date, now: Date): number {
|
|
69
|
+
return Math.max(0, Math.floor((now.getTime() - at.getTime()) / MS_PER_DAY));
|
|
70
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { ResetPolicy } from "../config/config";
|
|
5
|
+
import type { MemberState } from "../data/enums";
|
|
6
|
+
import type { TestersEvent } from "../data/event";
|
|
7
|
+
import { addDays, type DayKey, dayKey, dayRange, endOfDay } from "./days";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The clock: roster state and the estimated continuous-day streak, replayed from the event log.
|
|
11
|
+
*
|
|
12
|
+
* **Nothing here reads a stored counter, and that is the design.** A counter can only be overwritten,
|
|
13
|
+
* which means a correction destroys both the old value and the evidence that it changed. Replaying
|
|
14
|
+
* makes a correction an insert: add the event that was missing, recompute, and every snapshot written
|
|
15
|
+
* before it survives as an accurate record of what Pithy believed on that day. A trend chart is
|
|
16
|
+
* claiming to show exactly that, so it has to be true.
|
|
17
|
+
*
|
|
18
|
+
* **This module computes Pithy's estimate. It is not Google's number and cannot be.** Google's opt-in
|
|
19
|
+
* streak is computed by Play Console and exposed by no API — not the count, not the streak, not a
|
|
20
|
+
* tester's opt-out. What is replayed here is our own record of who followed our own confirmation link,
|
|
21
|
+
* which is a well-informed estimate of Google's figure and diverges from it whenever a tester opts out
|
|
22
|
+
* without telling anyone. Every field this module produces is named `estimated*` for that reason.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** One member's opt-in history, reduced to the transitions the count depends on. */
|
|
26
|
+
interface MemberTimeline {
|
|
27
|
+
memberId: string;
|
|
28
|
+
/** Every state-moving event, ascending. `nudged` and `reinvited` are dropped — they move nothing. */
|
|
29
|
+
transitions: { at: Date; kind: "invited" | "accepted" | "opted_in" | "lapsed" | "removed" }[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The event kinds that move a member's state. The other two are outreach history. */
|
|
33
|
+
const STATE_MOVING = new Set(["invited", "accepted", "opted_in", "lapsed", "removed"]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Group events into per-member timelines, ordered by when they *happened* rather than when they were
|
|
37
|
+
* written.
|
|
38
|
+
*
|
|
39
|
+
* The distinction is not pedantic. An opt-in confirmed on a phone with a stale clock, or a backfill
|
|
40
|
+
* that lands a week late, arrives with an `occurredAt` earlier than its `createdAt` — and replaying by
|
|
41
|
+
* insertion order would file it under the wrong day, which for a continuous-days counter is the
|
|
42
|
+
* difference between a completed window and a reset one.
|
|
43
|
+
*/
|
|
44
|
+
export function buildTimelines(events: readonly TestersEvent[]): Map<string, MemberTimeline> {
|
|
45
|
+
const timelines = new Map<string, MemberTimeline>();
|
|
46
|
+
for (const event of events) {
|
|
47
|
+
if (!STATE_MOVING.has(event.kind)) continue;
|
|
48
|
+
const timeline = timelines.get(event.memberId) ?? { memberId: event.memberId, transitions: [] };
|
|
49
|
+
timeline.transitions.push({
|
|
50
|
+
at: event.occurredAt,
|
|
51
|
+
kind: event.kind as MemberTimeline["transitions"][number]["kind"],
|
|
52
|
+
});
|
|
53
|
+
timelines.set(event.memberId, timeline);
|
|
54
|
+
}
|
|
55
|
+
for (const timeline of timelines.values()) {
|
|
56
|
+
timeline.transitions.sort((a, b) => a.at.getTime() - b.at.getTime());
|
|
57
|
+
}
|
|
58
|
+
return timelines;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A member's state as of the end of a given UTC day.
|
|
63
|
+
*
|
|
64
|
+
* The rule is "the last state-moving transition at or before this day wins", which is what makes
|
|
65
|
+
* re-opting-in work correctly: a tester who opted in, lapsed, and opted in again is counted from the
|
|
66
|
+
* second confirmation, and the days between do not count. Google's rule is the same — it explicitly
|
|
67
|
+
* does not credit fourteen non-consecutive days — so this is one of the few places the two models are
|
|
68
|
+
* known to agree.
|
|
69
|
+
*/
|
|
70
|
+
export function stateOn(timeline: MemberTimeline, day: DayKey): MemberState | undefined {
|
|
71
|
+
const cutoff = endOfDay(day).getTime();
|
|
72
|
+
let state: MemberState | undefined;
|
|
73
|
+
for (const transition of timeline.transitions) {
|
|
74
|
+
if (transition.at.getTime() > cutoff) break;
|
|
75
|
+
state = transition.kind;
|
|
76
|
+
}
|
|
77
|
+
return state;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** How many members were opted in at the end of a given UTC day — Pithy's estimate of Google's count. */
|
|
81
|
+
export function optedInOn(timelines: Map<string, MemberTimeline>, day: DayKey): number {
|
|
82
|
+
let count = 0;
|
|
83
|
+
for (const timeline of timelines.values()) {
|
|
84
|
+
if (stateOn(timeline, day) === "opted_in") count++;
|
|
85
|
+
}
|
|
86
|
+
return count;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What the clock reads for one cohort, as of one day. Every figure is an estimate. */
|
|
90
|
+
export interface CohortClock {
|
|
91
|
+
/** The day this reading describes. */
|
|
92
|
+
readonly on: DayKey;
|
|
93
|
+
/** Pithy's estimate of the opted-in count on this day. */
|
|
94
|
+
readonly estimatedOptedInCount: number;
|
|
95
|
+
/** Whether the estimate reached the target on this day. */
|
|
96
|
+
readonly meetsTarget: boolean;
|
|
97
|
+
/** Estimated count minus target. Zero means one lapse from a reset; it can go negative. */
|
|
98
|
+
readonly headroom: number;
|
|
99
|
+
/** The estimated unbroken run of at-target days ending on this day. */
|
|
100
|
+
readonly estimatedHeldDays: number;
|
|
101
|
+
/** The day the current run began, or null while below target. */
|
|
102
|
+
readonly estimatedWindowStartOn: DayKey | null;
|
|
103
|
+
/** Window days still to hold, floored at zero. */
|
|
104
|
+
readonly estimatedDaysRemaining: number;
|
|
105
|
+
/** How many times the run has broken since the cohort started. */
|
|
106
|
+
readonly resetCount: number;
|
|
107
|
+
/** Whether the run broke on this day — the annotation a cohort chart most needs. */
|
|
108
|
+
readonly resetToday: boolean;
|
|
109
|
+
/** The full daily series the reading was computed from, oldest first. Feeds the chart and the tests. */
|
|
110
|
+
readonly series: readonly { day: DayKey; optedIn: number; meetsTarget: boolean }[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** What `readClock` needs to know about the cohort it is reading. */
|
|
114
|
+
export interface ClockInput {
|
|
115
|
+
/** The cohort's creation moment — the first day of the series. */
|
|
116
|
+
readonly createdAt: Date;
|
|
117
|
+
/** The target in force. Read from the cohort row, not from config: the row's copy is frozen. */
|
|
118
|
+
readonly targetSize: number;
|
|
119
|
+
/** The window in force, in days. */
|
|
120
|
+
readonly windowDays: number;
|
|
121
|
+
/** What a dip below target does to the run. Pithy's assumption, not Google's documented behavior. */
|
|
122
|
+
readonly resetPolicy: ResetPolicy;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Read the clock for a cohort as of `now`.
|
|
127
|
+
*
|
|
128
|
+
* **On `resetPolicy`.** We do not know whether Play pauses or restarts the counter when a cohort dips
|
|
129
|
+
* to eleven. `reset` is the default and it is the pessimistic reading: it says you have further to go
|
|
130
|
+
* than `pause` would. That asymmetry is deliberate. A developer told they are on day fourteen when
|
|
131
|
+
* they are really on day three applies for production access, gets rejected, and blames the tool; a
|
|
132
|
+
* developer told day three when they were really on day fourteen waits an extra week and shrugs. Only
|
|
133
|
+
* one of those is worth defending against.
|
|
134
|
+
*/
|
|
135
|
+
export function readClock(input: ClockInput, events: readonly TestersEvent[], now: Date): CohortClock {
|
|
136
|
+
const timelines = buildTimelines(events);
|
|
137
|
+
const today = dayKey(now);
|
|
138
|
+
const start = dayKey(input.createdAt);
|
|
139
|
+
// A cohort created later than `now` (a clock skew, a seeded fixture) would otherwise produce an empty
|
|
140
|
+
// range and a series with no `today` in it, so the range is clamped to at least the current day.
|
|
141
|
+
const days = dayRange(start > today ? today : start, today);
|
|
142
|
+
|
|
143
|
+
const series = days.map((day) => {
|
|
144
|
+
const optedIn = optedInOn(timelines, day);
|
|
145
|
+
return { day, optedIn, meetsTarget: optedIn >= input.targetSize };
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
let resetCount = 0;
|
|
149
|
+
for (let index = 1; index < series.length; index++) {
|
|
150
|
+
// A break is a fall from at-target to below it. The first day of a cohort cannot be a break, and a
|
|
151
|
+
// cohort that has never reached target has never broken — it has simply not started.
|
|
152
|
+
if (series[index - 1]?.meetsTarget === true && series[index]?.meetsTarget === false) resetCount++;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const last = series[series.length - 1];
|
|
156
|
+
const meetsTarget = last?.meetsTarget ?? false;
|
|
157
|
+
const optedIn = last?.optedIn ?? 0;
|
|
158
|
+
|
|
159
|
+
let heldDays = 0;
|
|
160
|
+
if (input.resetPolicy === "pause") {
|
|
161
|
+
// Pause: every at-target day ever banked counts, whatever happened between them.
|
|
162
|
+
heldDays = series.filter((entry) => entry.meetsTarget).length;
|
|
163
|
+
} else {
|
|
164
|
+
// Reset: only the unbroken tail counts. Walk backwards until the run breaks.
|
|
165
|
+
for (let index = series.length - 1; index >= 0; index--) {
|
|
166
|
+
if (series[index]?.meetsTarget !== true) break;
|
|
167
|
+
heldDays++;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The run's start is only meaningful under `reset`, where the tail is contiguous. Under `pause` the
|
|
172
|
+
// banked days need not be adjacent, so there is no single day the run "began" — reporting one would
|
|
173
|
+
// be inventing a fact to fill a field.
|
|
174
|
+
const windowStartOn = input.resetPolicy === "reset" && heldDays > 0 ? addDays(today, -(heldDays - 1)) : null;
|
|
175
|
+
|
|
176
|
+
const previous = series[series.length - 2];
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
on: today,
|
|
180
|
+
estimatedOptedInCount: optedIn,
|
|
181
|
+
meetsTarget,
|
|
182
|
+
headroom: optedIn - input.targetSize,
|
|
183
|
+
estimatedHeldDays: heldDays,
|
|
184
|
+
estimatedWindowStartOn: windowStartOn,
|
|
185
|
+
estimatedDaysRemaining: Math.max(0, input.windowDays - heldDays),
|
|
186
|
+
resetCount,
|
|
187
|
+
resetToday: previous?.meetsTarget === true && !meetsTarget,
|
|
188
|
+
series,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/// <reference types="@cloudflare/vitest-plugin/types" />
|
|
5
|
+
|
|
6
|
+
// Bindings the Workers-runtime test project provides to `*.workers.test.ts`, matching the Miniflare
|
|
7
|
+
// config in `vitest.workers.config.ts`: the app `DB` database the `pithy_testers_*` tables live in,
|
|
8
|
+
// alongside the `pithy_auth_*` tables the activity reader joins against.
|
|
9
|
+
declare namespace Cloudflare {
|
|
10
|
+
interface Env {
|
|
11
|
+
DB: D1Database;
|
|
12
|
+
}
|
|
13
|
+
}
|