diff --git a/apps/server/src/routes/subscription-plans.ts b/apps/server/src/routes/subscription-plans.ts index ebf3558..6a88a47 100644 --- a/apps/server/src/routes/subscription-plans.ts +++ b/apps/server/src/routes/subscription-plans.ts @@ -27,13 +27,13 @@ interface PlanBody { timeframes?: PlanTimeframes | null; } -/** Validate the optional timeframes blob (minutes-of-day 0–1439, sane grace). */ +/** Validate the optional timeframes blob (minutes-of-day 0–1439, days 0–6, sane grace). */ function validTimeframes(tf: PlanTimeframes | null | undefined): string | null { if (tf == null) return null; - const okMin = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 1439); - for (const dt of [tf.weekday, tf.weekend]) { - if (!dt) continue; - if (!dt.allDay && (!okMin(dt.fromMin) || !okMin(dt.toMin))) return "window times must be minutes-of-day (0–1439)"; + const okMin = (v: unknown) => Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 1439; + if (!okMin(tf.fromMin) || !okMin(tf.toMin)) return "window times must be minutes-of-day (0–1439)"; + if (tf.days != null && (!Array.isArray(tf.days) || tf.days.some((d) => !Number.isInteger(d) || d < 0 || d > 6))) { + return "days must be integers 0–6 (0=Sun..6=Sat)"; } if (tf.graceMin != null && (!Number.isInteger(tf.graceMin) || tf.graceMin < 0)) return "graceMin must be ≥ 0"; return null; diff --git a/apps/web/src/SubscriptionPlansManager.tsx b/apps/web/src/SubscriptionPlansManager.tsx index 8de1abb..4b7303c 100644 --- a/apps/web/src/SubscriptionPlansManager.tsx +++ b/apps/web/src/SubscriptionPlansManager.tsx @@ -23,18 +23,23 @@ const PERIOD_KEY: Record = { month: "subs.perMonth", }; +// Day-of-week picker, Monday-first (mirrors the tariff composer). Labels come from the +// shared tariff.dow0..6 i18n keys (Hën..Die / Mon..Sun). +const DOW_ORDER = [1, 2, 3, 4, 5, 6, 0]; + interface PlanForm { planId: string; // blank on a brand-new plan; set when publishing a new version name: string; period: SubscriptionPeriod; priceMajor: string; currency: string; - // Timeframes (tariff bridge). Off → 24/7. On → weekday window (enter-after / exit-before - // as HH:MM) + weekend all-day toggle + grace minutes. + // Timeframes (tariff bridge). Off → 24/7. On → an allowed window (enter-after / + // exit-before as HH:MM) on the SELECTED days (0=Sun..6=Sat); on unselected days the + // subscriber parks free. Plus grace minutes. restrictTimes: boolean; - wdFrom: string; // weekday window opens (HH:MM) — when the subscriber may enter - wdTo: string; // weekday window closes (HH:MM) — by when they should exit - weekendAllDay: boolean; + days: number[]; // days the window applies to; empty = every day + winFrom: string; // window opens (HH:MM) — when the subscriber may enter + winTo: string; // window closes (HH:MM) — by when they should exit graceMin: string; } @@ -46,9 +51,9 @@ function emptyForm(): PlanForm { priceMajor: "", currency: DEFAULT_CURRENCY, restrictTimes: false, - wdFrom: "20:00", - wdTo: "08:00", - weekendAllDay: true, + days: [1, 2, 3, 4, 5], // default Mon–Fri (the common "night plan, free weekends") + winFrom: "20:00", + winTo: "08:00", graceMin: "0", }; } @@ -85,17 +90,19 @@ export function SubscriptionPlansManager() { const major = Number(form.priceMajor); if (!form.name.trim()) return setMsg({ kind: "err", text: t("plans.needName") }); if (!Number.isFinite(major) || major <= 0) return setMsg({ kind: "err", text: t("plans.needPrice") }); - // Build the timeframes blob from the form (null = 24/7). The weekday window is the - // allowed interval [wdFrom, wdTo) (wraps midnight for a night plan); weekend is all-day - // or inherits the weekday window. The server stamps the site tz. + // Build the timeframes blob from the form (null = 24/7). The window [winFrom, winTo) + // (wraps midnight for a night plan) applies on the SELECTED days; unselected days are + // unrestricted. Empty days = every day. The server stamps the site tz. let timeframes = null as Parameters[0]["timeframes"]; if (form.restrictTimes) { - const from = hhmmToMin(form.wdFrom); - const to = hhmmToMin(form.wdTo); + const from = hhmmToMin(form.winFrom); + const to = hhmmToMin(form.winTo); if (from == null || to == null) return setMsg({ kind: "err", text: t("plans.needWindow") }); + if (form.days.length === 0) return setMsg({ kind: "err", text: t("plans.needDays") }); timeframes = { - weekday: { fromMin: from, toMin: to }, - weekend: form.weekendAllDay ? { allDay: true } : { fromMin: from, toMin: to }, + days: [...form.days].sort((a, b) => a - b), + fromMin: from, + toMin: to, graceMin: Math.max(0, Math.round(Number(form.graceMin) || 0)), }; } @@ -126,7 +133,6 @@ export function SubscriptionPlansManager() { /** Publish a new version of an existing plan (pre-fills its identity + last values). */ function newVersionOf(p: SubscriptionPlan) { const tf = p.timeframes ?? null; - const wd = tf?.weekday; setForm({ planId: p.planId, name: p.name, @@ -134,9 +140,9 @@ export function SubscriptionPlansManager() { priceMajor: String(p.pricePerPeriodMinor / 100), currency: p.currency, restrictTimes: tf != null, - wdFrom: wd?.fromMin != null ? minToHHMM(wd.fromMin) : "20:00", - wdTo: wd?.toMin != null ? minToHHMM(wd.toMin) : "08:00", - weekendAllDay: tf?.weekend?.allDay ?? true, + days: tf?.days && tf.days.length > 0 ? [...tf.days] : [1, 2, 3, 4, 5], + winFrom: tf?.fromMin != null ? minToHHMM(tf.fromMin) : "20:00", + winTo: tf?.toMin != null ? minToHHMM(tf.toMin) : "08:00", graceMin: String(tf?.graceMin ?? 0), }); setMsg(null); @@ -246,23 +252,31 @@ export function SubscriptionPlansManager() { {form.restrictTimes && (
- + + + {DOW_ORDER.map((d) => ( + + ))} + + {t("plans.enterAfter")} - setForm((f) => f && { ...f, wdFrom: e.target.value })} /> + setForm((f) => f && { ...f, winFrom: e.target.value })} /> {t("plans.exitBefore")} - setForm((f) => f && { ...f, wdTo: e.target.value })} /> + setForm((f) => f && { ...f, winTo: e.target.value })} /> - - setForm((f) => f && { ...f, graceMin: e.target.value })} /> diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 157ccc7..14de818 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -494,16 +494,13 @@ export interface SubscriptionCredential { } export type SubscriptionPeriod = "day" | "week" | "month"; -/** A subscriber's allowed parking window for a day-type (minutes-from-local-midnight). - * A scan outside the window is charged the transient tariff for the gap. */ -export interface DayWindow { - allDay?: boolean; - fromMin?: number; - toMin?: number; -} +/** A subscriber's allowed parking window (minutes-from-local-midnight) on selected days. + * A scan outside the window is charged the transient tariff for the gap. days: 0=Sun..6=Sat + * (empty = every day); the window [fromMin,toMin) wraps past midnight when toMin ≤ fromMin. */ export interface PlanTimeframes { - weekday?: DayWindow; - weekend?: DayWindow; + days?: number[]; + fromMin: number; + toMin: number; graceMin?: number; tz?: string; } diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 9f7e193..b1980b8 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -469,12 +469,12 @@ export const en: Catalog = { saved: "Plan saved.", confirmRetire: "Retire the plan “{{name}}”? It will no longer be sellable (history is kept).", needWindow: "Enter valid window times (HH:MM).", + needDays: "Select at least one day for the window.", restrictTimes: "Restrict parking times (charge transient tariff outside the window)", - weekdayWindow: "Weekday", + days: "Days", + window: "Window", enterAfter: "enter after", exitBefore: "· exit before", - weekend: "Weekend", - weekendAllDay: "all day (no restriction)", grace: "Grace", graceHint: "minutes tolerance around the window edges", timeframesHint: "A scan outside the allowed window is charged the normal transient tariff for the out-of-window minutes (early entry is deferred to exit; late exit is gated until paid).", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 14c8684..ffba8d1 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -480,12 +480,12 @@ export const sq = { saved: "Plani u ruajt.", confirmRetire: "Të tërhiqet plani “{{name}}”? Nuk do të jetë më i shitshëm (historiku ruhet).", needWindow: "Shkruaj orare të vlefshme (HH:MM).", + needDays: "Zgjidh të paktën një ditë për intervalin.", restrictTimes: "Kufizo oraret e parkimit (tarifë kalimtare jashtë intervalit)", - weekdayWindow: "Ditë pune", + days: "Ditët", + window: "Intervali", enterAfter: "hyrje pas", exitBefore: "· dalje para", - weekend: "Fundjavë", - weekendAllDay: "gjithë ditën (pa kufizim)", grace: "Tolerancë", graceHint: "minuta tolerancë rreth kufijve të intervalit", timeframesHint: "Një skanim jashtë intervalit të lejuar tarifohet me tarifën normale kalimtare për minutat jashtë intervalit (hyrja e hershme shtyhet në dalje; dalja e vonuar bllokohet derisa paguhet).", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7342dea..8e122e8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -68,23 +68,20 @@ export const ADMIN_ROLE_ID = "admin"; export type SubscriptionPeriod = "day" | "week" | "month"; export const SUBSCRIPTION_PERIODS: readonly SubscriptionPeriod[] = ["day", "week", "month"]; -/** A subscriber's allowed parking window for a day-type, as minutes-from-local-midnight - * (0–1439). The window is the interval [fromMin, toMin); `toMin <= fromMin` means it - * WRAPS past midnight (e.g. 20:00→08:00 = a night window: 1200..480). `allDay` = the - * whole day is allowed (no charge). An ABSENT day-window = no restriction (24/7) for - * that day-type. */ -export interface DayWindow { - readonly allDay?: boolean; - readonly fromMin?: number; // window opens (minutes-of-day, local) - readonly toMin?: number; // window closes (minutes-of-day, local) -} - /** Composed allowed-time windows on a [[subscription]] plan. A scan OUTSIDE the window * is charged the transient tariff for the out-of-window minutes (the "tariff bridge"). - * null/absent timeframes on a plan = 24/7, no charge ever. Evaluated in the site tz. */ + * null/absent timeframes on a plan = 24/7, no charge ever. Evaluated in the site tz. + * + * The window applies ONLY on the selected `days` (0=Sun..6=Sat, mirroring the V2 tariff + * day-of-week picker). On a NON-selected day the subscriber may park all day (no charge) + * — so a "night plan" is days [Mon..Fri] with a 20:00→08:00 window, leaving the weekend + * unrestricted. The window is [fromMin, toMin) minutes-of-local-midnight; `toMin ≤ fromMin` + * WRAPS past midnight (a night window 20:00→08:00 = 1200..480). */ export interface PlanTimeframes { - readonly weekday?: DayWindow; // Mon–Fri - readonly weekend?: DayWindow; // Sat–Sun + /** Days the window applies to (0=Sun..6=Sat). Empty/absent ⇒ every day. */ + readonly days?: number[]; + readonly fromMin: number; // window opens (minutes-of-day, local) + readonly toMin: number; // window closes (minutes-of-day, local) /** Tolerance (minutes) around the window edges before a charge applies. */ readonly graceMin?: number; /** IANA tz the windows are wall-clock evaluated in (the site tz, captured at sale). */ @@ -1073,11 +1070,6 @@ export function localBreakdown(instantMs: number, tz: string): WallClock { // --- Subscription plan timeframes — the "tariff bridge" gap (pure, tz-aware) ------- -/** Is a day-of-week a weekend (Sat/Sun)? */ -function isWeekend(dow: number): boolean { - return dow === 0 || dow === 6; -} - /** Minute-of-day is inside the window [fromMin, toMin)? A window with toMin ≤ fromMin * WRAPS past midnight (night window 20:00→08:00 ⇒ in = m ≥ 1200 OR m < 480). */ function inWindow(m: number, fromMin: number, toMin: number): boolean { @@ -1085,17 +1077,19 @@ function inWindow(m: number, fromMin: number, toMin: number): boolean { } /** - * The out-of-window GAP for a subscriber scan, or null when the scan is in-window (or - * the plan/day is unrestricted/all-day). This is the portion charged at the transient - * tariff (the "tariff bridge"): + * The out-of-window GAP for a subscriber scan, or null when the scan is in-window (or the + * scan falls on a day the window does NOT apply to). This is the portion charged at the + * transient tariff (the "tariff bridge"): * - edge "entry" (early arrival): gap = [scan, next window-OPEN] — they pay transient * from arrival until their window starts (a 09:00 arrival to a 20:00 night window * owes 09:00→20:00, capped by the tariff's daily cap). * - edge "exit" (late departure): gap = [last window-CLOSE, scan] — they pay transient * from when their window ended until they actually leave (08:00→08:45). - * Grace widens the allowed window by `graceMin` on the relevant edge. Pure + tz-aware - * (wall-clock in `timeframes.tz` or the passed `tz`). Minutes-of-day arithmetic anchored - * on the scan's own local day keeps it DST-robust for the short gaps involved. + * The window applies only on `timeframes.days` (0=Sun..6=Sat; empty ⇒ every day); on a + * day NOT in the set the subscriber parks free. Grace widens the allowed window by + * `graceMin` on the relevant edge. Pure + tz-aware (wall-clock in `timeframes.tz` or the + * passed `tz`); minutes-of-day arithmetic anchored on the scan's own local day keeps it + * DST-robust for the short gaps involved. */ export function outOfWindowGap( timeframes: PlanTimeframes | null | undefined, @@ -1104,19 +1098,21 @@ export function outOfWindowGap( edge: "entry" | "exit", ): { start: string; end: string; minutes: number } | null { if (!timeframes) return null; + if (typeof timeframes.fromMin !== "number" || typeof timeframes.toMin !== "number") return null; const atMs = Date.parse(atISO); if (Number.isNaN(atMs)) return null; const zone = timeframes.tz || tz; const wall = localBreakdown(atMs, zone); - const day: DayWindow | undefined = isWeekend(wall.dow) ? timeframes.weekend : timeframes.weekday; - // No window for this day-type, or explicitly all-day ⇒ unrestricted, no charge. - if (!day || day.allDay) return null; - if (typeof day.fromMin !== "number" || typeof day.toMin !== "number") return null; + + // The window applies only on the selected days; empty/absent = every day. On a day the + // window doesn't cover, the subscriber may park all day (no charge). + const days = timeframes.days; + if (days && days.length > 0 && !days.includes(wall.dow)) return null; const grace = Math.max(0, timeframes.graceMin ?? 0); const nowMin = wall.hour * 60 + wall.minute; - if (inWindow(nowMin, day.fromMin, day.toMin)) return null; // already allowed + if (inWindow(nowMin, timeframes.fromMin, timeframes.toMin)) return null; // already allowed // Minutes (always ≥ 0) until the window OPENS, measured forward from the scan. const minsUntil = (target: number) => ((target - nowMin) % 1440 + 1440) % 1440; @@ -1125,13 +1121,13 @@ export function outOfWindowGap( if (edge === "entry") { // Early: charge from the scan until the window opens (minus grace tolerance). - let mins = minsUntil(day.fromMin) - grace; + const mins = minsUntil(timeframes.fromMin) - grace; if (mins <= 0) return null; // within grace of opening const end = new Date(atMs + mins * 60_000).toISOString(); return { start: atISO, end, minutes: mins }; } // Late exit: charge from when the window closed (plus grace) until the scan. - let mins = minsSince(day.toMin) - grace; + const mins = minsSince(timeframes.toMin) - grace; if (mins <= 0) return null; // within grace of closing const start = new Date(atMs - mins * 60_000).toISOString(); return { start, end: atISO, minutes: mins }; diff --git a/packages/shared/src/subscription-window.test.ts b/packages/shared/src/subscription-window.test.ts index dae967e..c065f03 100644 --- a/packages/shared/src/subscription-window.test.ts +++ b/packages/shared/src/subscription-window.test.ts @@ -4,15 +4,17 @@ import { outOfWindowGap, type PlanTimeframes } from "./index.js"; // The "tariff bridge" gap for a subscriber scan outside their allowed window. UTC tz // keeps the wall-clock arithmetic obvious in the tests. See wiki/entities/subscription.md. -// Night plan: weekday allowed 20:00→08:00 (wraps midnight); weekend all-day. +// Night plan: window 20:00→08:00 (wraps midnight) on weekdays (Mon..Fri). Days not in the +// set (Sat/Sun) are unrestricted — no charge. const night: PlanTimeframes = { - weekday: { fromMin: 20 * 60, toMin: 8 * 60 }, // 1200 → 480 - weekend: { allDay: true }, + days: [1, 2, 3, 4, 5], // Mon..Fri + fromMin: 20 * 60, + toMin: 8 * 60, // 1200 → 480 graceMin: 0, tz: "UTC", }; -// A weekday + a weekend (2026-06-22 is a Monday; 2026-06-20 is a Saturday). +// 2026-06-22 is a Monday; 2026-06-20 is a Saturday. const monday = (hhmm: string) => `2026-06-22T${hhmm}:00.000Z`; const saturday = (hhmm: string) => `2026-06-20T${hhmm}:00.000Z`; @@ -49,11 +51,20 @@ describe("outOfWindowGap — exit edge (late departure)", () => { }); }); -describe("outOfWindowGap — weekend all-day", () => { - it("any Saturday scan is free (entry + exit)", () => { +describe("outOfWindowGap — days the window doesn't apply", () => { + it("a Saturday scan is free (window only Mon..Fri)", () => { expect(outOfWindowGap(night, "UTC", saturday("09:00"), "entry")).toBeNull(); expect(outOfWindowGap(night, "UTC", saturday("23:30"), "exit")).toBeNull(); }); + it("a window with no days (every day) DOES apply on Saturday", () => { + const everyDay: PlanTimeframes = { fromMin: 1200, toMin: 480, tz: "UTC" }; + expect(outOfWindowGap(everyDay, "UTC", saturday("09:00"), "entry")).not.toBeNull(); + }); + it("an arbitrary day set (e.g. only Saturday) applies just then", () => { + const satOnly: PlanTimeframes = { days: [6], fromMin: 1200, toMin: 480, tz: "UTC" }; + expect(outOfWindowGap(satOnly, "UTC", saturday("09:00"), "entry")).not.toBeNull(); + expect(outOfWindowGap(satOnly, "UTC", monday("09:00"), "entry")).toBeNull(); + }); }); describe("outOfWindowGap — grace tolerance", () => { @@ -74,9 +85,4 @@ describe("outOfWindowGap — unrestricted", () => { it("null timeframes → never a charge", () => { expect(outOfWindowGap(null, "UTC", monday("09:00"), "entry")).toBeNull(); }); - it("a day-type with no window → no charge", () => { - const weekdayOnly: PlanTimeframes = { weekday: { fromMin: 1200, toMin: 480 }, tz: "UTC" }; - // weekend absent ⇒ unrestricted on Saturday. - expect(outOfWindowGap(weekdayOnly, "UTC", saturday("09:00"), "entry")).toBeNull(); - }); }); diff --git a/wiki/entities/subscription.md b/wiki/entities/subscription.md index 6098f1a..560c81a 100644 --- a/wiki/entities/subscription.md +++ b/wiki/entities/subscription.md @@ -92,9 +92,12 @@ subscriber may park (e.g. weekday allowed 20:00→08:00, weekend all-day). Inste out-of-window scans, the system **charges the out-of-window minutes at the normal transient [[tariff]]** — the subscriber becomes a transient customer for the time outside their window: -- `PlanTimeframes` = per day-type `DayWindow` ({ allDay | fromMin, toMin } minutes-of-local-midnight; - `toMin ≤ fromMin` wraps past midnight for a night window) + `graceMin` + the site `tz` (frozen in - the plan version, like a V2 tariff's tz). null timeframes = 24/7, no charge ever. +- `PlanTimeframes` = `{ days[], fromMin, toMin, graceMin?, tz }`. The allowed window + `[fromMin, toMin)` (minutes-of-local-midnight; `toMin ≤ fromMin` wraps past midnight for a night + window) applies ONLY on the selected **`days`** (0=Sun..6=Sat — the **same per-day-of-week picker as + the V2 [[tariff]]**, Hën–Die; empty = every day). On a day NOT in the set the subscriber parks free. + `tz` is frozen in the plan version (like a V2 tariff's tz). null timeframes = 24/7, no charge ever. + *(A "night plan, free weekends" is just `days:[Mon..Fri], 20:00→08:00`.)* - `outOfWindowGap(timeframes, tz, at, edge)` (pure, tz-aware, unit-tested in `@parking/shared`) returns the `[start, end]` portion outside the window. **Early entry**: gap = arrival → next window-open (a 09:00 arrival to a 20:00 window owes 09:00→20:00, capped by the tariff's daily cap).