From c4cd1e3646935fa5688365d48145cf32ff4fb666 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 21 Sep 2026 13:15:56 +0200 Subject: [PATCH] feat(intake-links): per-(link, project) daily-cap pure helpers Phase 9a Task 2. Four pure helpers: defaultCapFor, isDailyCapReached, nextCount, computeDateForCounter. 7 unit tests cover the caps boundary (cap=0 means 'no submissions today'; todayCount === cap is reached), the count increment, and the local-date format. Note: project convention puts unit tests next to source (src/**/*.test.ts), not under tests/unit/. Adjusted from the plan. --- src/lib/intake-links/submissions.test.ts | 48 ++++++++++++++++++++++++ src/lib/intake-links/submissions.ts | 40 ++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 src/lib/intake-links/submissions.test.ts create mode 100644 src/lib/intake-links/submissions.ts diff --git a/src/lib/intake-links/submissions.test.ts b/src/lib/intake-links/submissions.test.ts new file mode 100644 index 0000000..db5f593 --- /dev/null +++ b/src/lib/intake-links/submissions.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; + +import { + computeDateForCounter, + defaultCapFor, + isDailyCapReached, + nextCount, +} from "@/lib/intake-links/submissions"; + +describe("defaultCapFor", () => { + it("returns the row cap when set, otherwise 100", () => { + expect(defaultCapFor(0)).toBe(100); + expect(defaultCapFor(50)).toBe(50); + expect(defaultCapFor(10000)).toBe(10000); + }); + + it("treats undefined as 'use default'", () => { + expect(defaultCapFor(undefined)).toBe(100); + }); +}); + +describe("isDailyCapReached", () => { + it("is false when the counter is below the cap", () => { + expect(isDailyCapReached(0, 100)).toBe(false); + expect(isDailyCapReached(99, 100)).toBe(false); + }); + it("is true when the counter equals or exceeds the cap", () => { + expect(isDailyCapReached(100, 100)).toBe(true); + expect(isDailyCapReached(101, 100)).toBe(true); + }); + it("treats cap=0 as 'no submissions today'", () => { + expect(isDailyCapReached(0, 0)).toBe(true); + }); +}); + +describe("nextCount", () => { + it("increments by one", () => { + expect(nextCount(0)).toBe(1); + expect(nextCount(7)).toBe(8); + }); +}); + +describe("computeDateForCounter", () => { + it("formats a Date as YYYY-MM-DD in local time", () => { + const d = new Date(2026, 8, 21, 12, 0, 0); + expect(computeDateForCounter(d)).toBe("2026-09-21"); + }); +}); diff --git a/src/lib/intake-links/submissions.ts b/src/lib/intake-links/submissions.ts new file mode 100644 index 0000000..46fe63c --- /dev/null +++ b/src/lib/intake-links/submissions.ts @@ -0,0 +1,40 @@ +/** + * Per-(link, project) daily-cap helpers for the Phase 9 picker path. + * + * Phase 8 stored `submission_count_today` on the link row itself, which + * silently meant "across all projects on this link". With one link per + * client, that cap stops being meaningful — one busy project could lock + * the rest of the client's projects out. Phase 9 moves the counter onto a + * new `intake_link_submissions` collection, one row per + * (link, project, date), so each project has its own daily budget. + * + * These helpers are pure. The PocketBase reads/writes live in + * `src/lib/intake-links/index.ts` next to `resolveIntakeLink`. + */ + +export const DEFAULT_DAILY_CAP = 100; + +/** Resolve the effective cap from the row's optional field. */ +export function defaultCapFor(rowCap: number | undefined): number { + if (typeof rowCap === "number" && rowCap > 0) return rowCap; + return DEFAULT_DAILY_CAP; +} + +/** True when today's count has reached or exceeded the cap. */ +export function isDailyCapReached(todayCount: number, cap: number): boolean { + if (cap <= 0) return true; // explicit zero means "no submissions today" + return todayCount >= cap; +} + +/** Increment. Pure. */ +export function nextCount(todayCount: number): number { + return todayCount + 1; +} + +/** Local-date string in YYYY-MM-DD form. Same shape as the Phase 8 helper. */ +export function computeDateForCounter(now: Date = new Date()): string { + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + const d = String(now.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +}