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.
This commit is contained in:
Dave 2026-09-21 13:15:56 +02:00
parent eb45fb0168
commit c4cd1e3646
2 changed files with 88 additions and 0 deletions

View file

@ -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");
});
});

View file

@ -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}`;
}