gymmonk-schema 0.46.2 → 0.48.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/dist/membership-status.d.ts +15 -1
- package/dist/membership-status.d.ts.map +1 -1
- package/dist/membership-status.js +27 -1
- package/dist/membership-status.js.map +1 -1
- package/dist/session.d.ts +1 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +23 -0
- package/dist/session.js.map +1 -1
- package/package.json +1 -1
|
@@ -159,6 +159,15 @@ export type DisplayMembershipStatus = 'pending' | 'rejected' | 'active' | 'expir
|
|
|
159
159
|
* warning band. Everything else is a straight mapping, so a screen and the
|
|
160
160
|
* door can never disagree about whether somebody is live — only about how
|
|
161
161
|
* loudly to say it.
|
|
162
|
+
*
|
|
163
|
+
* EXHAUSTIVE over `MembershipStatus`, and that is load-bearing rather than
|
|
164
|
+
* tidy. This used to end in a `default` branch that meant "live", so every
|
|
165
|
+
* stored value nobody had thought about was adopted as `active` on sight:
|
|
166
|
+
* `invited` reached a screen as a running membership while `isMembershipLive`
|
|
167
|
+
* turned the same person away at the door, and the roster's active FILTER
|
|
168
|
+
* counted them in while the header count beside it — which reads the same
|
|
169
|
+
* question out of Mongo — left them out. Naming every case makes adding one
|
|
170
|
+
* to the enum a build failure until somebody decides what a screen calls it.
|
|
162
171
|
*/
|
|
163
172
|
export declare function displayMembershipStatus(membership: MembershipStatusInput, today: string): DisplayMembershipStatus;
|
|
164
173
|
/**
|
|
@@ -170,8 +179,13 @@ export declare function displayMembershipStatus(membership: MembershipStatusInpu
|
|
|
170
179
|
* exactly the kind of rule each list would otherwise re-derive slightly
|
|
171
180
|
* differently, and one of them would forget `expiring` and quietly hide every
|
|
172
181
|
* member in their last month.
|
|
182
|
+
*
|
|
183
|
+
* `null` means no membership was ever sold, which is not a lifecycle state and
|
|
184
|
+
* is certainly not live. Accepted here rather than pushed back onto callers so
|
|
185
|
+
* that a roster row with no plan is one `if` for everybody instead of a
|
|
186
|
+
* judgement each list makes for itself.
|
|
173
187
|
*/
|
|
174
|
-
export declare function isLiveDisplayStatus(status: DisplayMembershipStatus): boolean;
|
|
188
|
+
export declare function isLiveDisplayStatus(status: DisplayMembershipStatus | null): boolean;
|
|
175
189
|
/**
|
|
176
190
|
* The words on the lifecycle pill.
|
|
177
191
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"membership-status.d.ts","sourceRoot":"","sources":["../src/membership-status.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAMxB;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,sBAAsB;;;;;;;;EAQjC,CAAC;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE,gFAAgF;AAChF,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,gBAAgB,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,yBAAyB,CACvC,UAAU,EAAE,qBAAqB,EACjC,KAAK,EAAE,MAAM,GACZ,gBAAgB,CA+BlB;AAED,mEAAmE;AACnE,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,qBAAqB,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAE1F;AAID;;;;;;;GAOG;AACH,eAAO,MAAM,+BAA+B,KAAK,CAAC;AAElD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,4BAA4B,IAAI,CAAC;AAE9C;;;;;;GAMG;AACH,MAAM,MAAM,uBAAuB,GAC/B,SAAS,GACT,UAAU,GACV,QAAQ,GACR,UAAU,GACV,QAAQ,GACR,SAAS,CAAC;AAEd
|
|
1
|
+
{"version":3,"file":"membership-status.d.ts","sourceRoot":"","sources":["../src/membership-status.ts"],"names":[],"mappings":"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"}
|
|
@@ -177,6 +177,15 @@ export const MEMBERSHIP_EXPIRY_ALERT_DAYS = 7;
|
|
|
177
177
|
* warning band. Everything else is a straight mapping, so a screen and the
|
|
178
178
|
* door can never disagree about whether somebody is live — only about how
|
|
179
179
|
* loudly to say it.
|
|
180
|
+
*
|
|
181
|
+
* EXHAUSTIVE over `MembershipStatus`, and that is load-bearing rather than
|
|
182
|
+
* tidy. This used to end in a `default` branch that meant "live", so every
|
|
183
|
+
* stored value nobody had thought about was adopted as `active` on sight:
|
|
184
|
+
* `invited` reached a screen as a running membership while `isMembershipLive`
|
|
185
|
+
* turned the same person away at the door, and the roster's active FILTER
|
|
186
|
+
* counted them in while the header count beside it — which reads the same
|
|
187
|
+
* question out of Mongo — left them out. Naming every case makes adding one
|
|
188
|
+
* to the enum a build failure until somebody decides what a screen calls it.
|
|
180
189
|
*/
|
|
181
190
|
export function displayMembershipStatus(membership, today) {
|
|
182
191
|
const effective = effectiveMembershipStatus(membership, today);
|
|
@@ -187,14 +196,26 @@ export function displayMembershipStatus(membership, today) {
|
|
|
187
196
|
return 'rejected';
|
|
188
197
|
case 'paused':
|
|
189
198
|
return 'paused';
|
|
199
|
+
/**
|
|
200
|
+
* All three are over, and a viewer can do exactly one thing about any of
|
|
201
|
+
* them: buy again. `invited` is here because it is not live at the door
|
|
202
|
+
* either — a membership nobody claimed has never run — and a screen
|
|
203
|
+
* saying otherwise would be the screen disagreeing with the lock.
|
|
204
|
+
*/
|
|
190
205
|
case 'expired':
|
|
191
206
|
case 'cancelled':
|
|
207
|
+
case 'invited':
|
|
192
208
|
return 'expired';
|
|
193
|
-
|
|
209
|
+
case 'active': {
|
|
194
210
|
// Live. The only question left is how close the end is.
|
|
195
211
|
const left = daysBetween(today, membership.endDate);
|
|
196
212
|
return left <= MEMBERSHIP_EXPIRING_WINDOW_DAYS ? 'expiring' : 'active';
|
|
197
213
|
}
|
|
214
|
+
default: {
|
|
215
|
+
// Unreachable today, and a COMPILE error the day the enum grows.
|
|
216
|
+
const unhandled = effective;
|
|
217
|
+
return unhandled;
|
|
218
|
+
}
|
|
198
219
|
}
|
|
199
220
|
}
|
|
200
221
|
/**
|
|
@@ -206,6 +227,11 @@ export function displayMembershipStatus(membership, today) {
|
|
|
206
227
|
* exactly the kind of rule each list would otherwise re-derive slightly
|
|
207
228
|
* differently, and one of them would forget `expiring` and quietly hide every
|
|
208
229
|
* member in their last month.
|
|
230
|
+
*
|
|
231
|
+
* `null` means no membership was ever sold, which is not a lifecycle state and
|
|
232
|
+
* is certainly not live. Accepted here rather than pushed back onto callers so
|
|
233
|
+
* that a roster row with no plan is one `if` for everybody instead of a
|
|
234
|
+
* judgement each list makes for itself.
|
|
209
235
|
*/
|
|
210
236
|
export function isLiveDisplayStatus(status) {
|
|
211
237
|
return status === 'active' || status === 'expiring';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"membership-status.js","sourceRoot":"","sources":["../src/membership-status.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,8EAA8E;AAC9E,6CAA6C;AAC7C,8EAA8E;AAE9E;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,CAAC,IAAI,CAAC;IAC3C,SAAS;IACT,UAAU;IACV,SAAS;IACT,QAAQ;IACR,QAAQ;IACR,SAAS;IACT,WAAW;CACZ,CAAC,CAAC;AAWH;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,UAAU,yBAAyB,CACvC,UAAiC,EACjC,KAAa;IAEb,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,GAAG,UAAU,CAAC;IAE7D,6CAA6C;IAC7C,IACE,MAAM,KAAK,SAAS;QACpB,MAAM,KAAK,UAAU;QACrB,MAAM,KAAK,WAAW;QACtB,MAAM,KAAK,SAAS;QACpB,MAAM,KAAK,SAAS,EACpB,CAAC;QACD,OAAO,MAAM,CAAC;IAChB,CAAC;IAED;;;;;;;;;OASG;IACH,IAAI,MAAM,KAAK,QAAQ,EAAE,CAAC;QACxB,IAAI,CAAC,QAAQ;YAAE,OAAO,QAAQ,CAAC;QAC/B,MAAM,UAAU,GAAG,CAAC,UAAU,IAAI,KAAK,IAAI,UAAU,CAAC;QACtD,IAAI,UAAU,IAAI,KAAK,IAAI,QAAQ;YAAE,OAAO,QAAQ,CAAC;IACvD,CAAC;IAED,OAAO,OAAO,IAAI,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC;AACjD,CAAC;AAED,mEAAmE;AACnE,MAAM,UAAU,gBAAgB,CAAC,UAAiC,EAAE,KAAa;IAC/E,OAAO,yBAAyB,CAAC,UAAU,EAAE,KAAK,CAAC,KAAK,QAAQ,CAAC;AACnE,CAAC;AAED,+EAA+E;AAE/E;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,+BAA+B,GAAG,EAAE,CAAC;AAElD;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,MAAM,4BAA4B,GAAG,CAAC,CAAC;AAiB9C;;;;;;;GAOG;AACH,MAAM,UAAU,uBAAuB,CACrC,UAAiC,EACjC,KAAa;IAEb,MAAM,SAAS,GAAG,yBAAyB,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;IAE/D,QAAQ,SAAS,EAAE,CAAC;QAClB,KAAK,SAAS;YACZ,OAAO,SAAS,CAAC;QACnB,KAAK,UAAU;YACb,OAAO,UAAU,CAAC;QACpB,KAAK,QAAQ;YACX,OAAO,QAAQ,CAAC;QAClB,KAAK,SAAS,CAAC;QACf,KAAK,WAAW;YACd,OAAO,SAAS,CAAC;QACnB,OAAO,CAAC,CAAC,CAAC;YACR,wDAAwD;YACxD,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,EAAE,UAAU,CAAC,OAAO,CAAC,CAAC;YACpD,OAAO,IAAI,IAAI,+BAA+B,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC;QACzE,CAAC;IACH,CAAC;AACH,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAA+B;IACjE,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,UAAU,CAAC;AACtD,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAA4C;IAC9E,OAAO,EAAE,kBAAkB;IAC3B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,QAAQ;IAChB,QAAQ,EAAE,UAAU;IACpB,MAAM,EAAE,QAAQ;IAChB,OAAO,EAAE,SAAS;CACnB,CAAC;AAEF,8EAA8E;AAC9E,6CAA6C;AAC7C,8EAA8E;AAE9E,4EAA4E;AAC5E,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAC;AA6EnC;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,KAAqB,EAAE,KAAa;IAChE,MAAM,EAAE,gBAAgB,EAAE,aAAa,EAAE,GAAG,KAAK,CAAC;IAClD,MAAM,WAAW,GAAG,KAAK,CAAC,WAAW,IAAI,IAAI,CAAC;IAC9C,MAAM,IAAI,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,YAAY,EAAE,CAAC,EAAW,CAAC;IAE7E;;;;OAIG;IACH,IAAI,gBAAgB,KAAK,IAAI,EAAE,CAAC;QAC9B,OAAO,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IACvE,CAAC;IAED;;;;;;;OAOG;IACH,IAAI,gBAAgB,KAAK,SAAS,EAAE,CAAC;QACnC,OAAO,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IACvE,CAAC;IAED,0DAA0D;IAC1D,IAAI,gBAAgB,KAAK,UAAU,EAAE,CAAC;QACpC,OAAO,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;IACvE,CAAC;IAED;;;;;;OAMG;IACH,IAAI,gBAAgB,KAAK,WAAW,EAAE,CAAC;QACrC,OAAO,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,SAAS,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IAC1E,CAAC;IAED,gEAAgE;IAChE,IAAI,WAAW,IAAI,WAAW,GAAG,CAAC,EAAE,CAAC;QACnC,OAAO;YACL,GAAG,IAAI;YACP,MAAM,EAAE,SAAS;YACjB,SAAS,EAAE,IAAI;YACf,KAAK,EAAE,IAAI;YACX,WAAW;YACX,YAAY,EAAE,aAAa;SAC5B,CAAC;IACJ,CAAC;IAED;;;;;;OAMG;IACH,IAAI,aAAa,GAAG,CAAC,EAAE,CAAC;QACtB,OAAO,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,YAAY,EAAE,aAAa,EAAE,CAAC;IAClG,CAAC;IAED,MAAM,QAAQ,GAAG,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,KAAK,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAE9E;;;;;;OAMG;IACH,IAAI,QAAQ,KAAK,IAAI,IAAI,QAAQ,GAAG,CAAC,EAAE,CAAC;QACtC,OAAO,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;IAC/E,CAAC;IAED,IAAI,QAAQ,KAAK,IAAI,IAAI,QAAQ,IAAI,CAAC,IAAI,QAAQ,IAAI,iBAAiB,EAAE,CAAC;QACxE,OAAO,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;IACjF,CAAC;IAED;;;OAGG;IACH,OAAO,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC;AAC/E,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAC3B;IACE,OAAO,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,GAAG,MAAM,YAAY,MAAM,CAAC,CAAC,CAAC,WAAW,IAAI,CAAC,EAAE,KAAK,CAAC,EAAE;IAChF,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,CAAC,GAAG,MAAM,MAAM;IACvC,QAAQ,EAAE,GAAG,EAAE,CAAC,kBAAkB;IAClC,OAAO,EAAE,GAAG,EAAE,CAAC,iBAAiB;IAChC,MAAM,EAAE,GAAG,EAAE,CAAC,aAAa;IAC3B,UAAU,EAAE,CAAC,CAAC,EAAE,EAAE,CAChB,CAAC,CAAC,QAAQ,KAAK,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,UAAU,MAAM,CAAC,CAAC,CAAC,QAAQ,IAAI,CAAC,EAAE,KAAK,CAAC,EAAE;IAC7E,SAAS,EAAE,GAAG,EAAE,CAAC,aAAa;IAC9B,SAAS,EAAE,GAAG,EAAE,CAAC,WAAW;IAC5B,IAAI,EAAE,GAAG,EAAE,CAAC,MAAM;CACnB,CAAC;AAEJ,8EAA8E;AAC9E,iBAAiB;AACjB,8EAA8E;AAE9E;;;;;;GAMG;AACH,MAAM,UAAU,WAAW,CAAC,IAAY,EAAE,EAAU;IAClD,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,IAAI,gBAAgB,CAAC,CAAC;IAC9C,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,gBAAgB,CAAC,CAAC;IAC5C,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC;IACjD,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,UAAU,CAAC,CAAC;AAC1C,CAAC;AAED,+BAA+B;AAC/B,SAAS,MAAM,CAAC,CAAS,EAAE,IAAY;IACrC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC;AACpD,CAAC","sourcesContent":["/**\n * gymmonk-schema — Membership status, all of it, in one place\n * ============================================================\n * Everything the product knows about \"where does this member stand\" lives\n * here: the stored lifecycle, the effective one, the words shown on screen,\n * the money reading, and the three thresholds that decide \"soon\".\n *\n * ── Why this module exists ──────────────────────────────────────────────────\n * These facts were spread across five files in two repos. The stored enum and\n * the effective rule were in `membership.ts`; the DISPLAY vocabulary and its\n * derivation were in the web client's `shared/lib/format.ts`, out of the\n * backend's reach; the labels were inline in a pill component; the fee\n * vocabulary was in an owner-console file and took a web VIEW TYPE as its\n * input, so nothing else could call it. Two of the web's own feature folders\n * had also declared `MembershipStatus` as an alias of the DISPLAY type — the\n * same name as the stored one, meaning something different, in the same\n * codebase.\n *\n * ── There are TWO axes, and collapsing them would lose information ──────────\n * They are correlated, not identical, and they answer different questions:\n *\n * LIFECYCLE can this person train today? (the door asks this)\n * FEES does this person owe money? (the owner's roster asks this)\n *\n * A member part-way through a term they have half-paid is `active` on the\n * first axis and `unpaid` on the second, and gyms take part payments every\n * week. One flat enum would have to pick a side.\n *\n * The axes relate like this, and `readFeeStatus` encodes exactly this table:\n *\n * lifecycle money owed → fee status\n * active no → paid (or due-soon near the end of the term)\n * active yes → unpaid\n * expired yes → overdue\n * expired no → lapsed\n * pending n/a → pending (the GYM owes the next move)\n * none n/a → no-plan (the OWNER owes the next move)\n *\n * ── What is NOT here ────────────────────────────────────────────────────────\n * Colours. A tone is a Tailwind class, it belongs to whichever client is\n * painting, and there is exactly one consumer of each. The words live here\n * because two surfaces must never call the same state different things.\n *\n * @module gymmonk-schema/membership-status\n */\n\nimport { z } from 'zod';\n\n// ═══════════════════════════════════════════════════════════════════════════\n// AXIS 1 — LIFECYCLE: can this member train?\n// ═══════════════════════════════════════════════════════════════════════════\n\n/**\n * The STORED lifecycle column.\n *\n * `pending` is where every membership STARTS, whoever created it. Choosing a\n * plan is not the same event as paying for one, and the two used to be welded\n * together: the moment a member tapped a plan it went live, and the moment an\n * owner assigned one at the desk it went live, whether or not any money had\n * changed hands. A gym then had no way to tell a paid member from an optimistic\n * one, and no lever to withhold access from the second.\n *\n * The only transition out of `pending` is the gym marking the payment done.\n * That is deliberately a HUMAN act by the owner or a manager rather than a\n * gateway callback: these gyms take cash at the desk and UPI transfers on a\n * phone, so the person who knows the money arrived is the person at the gym.\n *\n * `rejected` is the other way out, for a screenshot that shows nothing, the\n * wrong amount, or a transfer that never landed. It is a dead end — the member\n * requests again rather than the gym reopening a closed decision.\n *\n * `invited` predates all of this and nothing writes it. Left in the union\n * because removing a value from a stored enum is not worth a migration, and old\n * rows still have to parse.\n *\n * `expiring` is NOT here — it is derived from `endDate` at the edge. See\n * `DisplayMembershipStatus`.\n */\nexport const membershipStatusSchema = z.enum([\n 'pending',\n 'rejected',\n 'invited',\n 'active',\n 'paused',\n 'expired',\n 'cancelled',\n]);\nexport type MembershipStatus = z.infer<typeof membershipStatusSchema>;\n\n/** The fields any status question needs. Deliberately not a full membership. */\nexport interface MembershipStatusInput {\n status: MembershipStatus;\n endDate: string;\n pausedFrom?: string | null;\n pausedTo?: string | null;\n}\n\n/**\n * What a membership IS right now, as against what was last written to its\n * `status` column.\n *\n * ── Why this has to exist ───────────────────────────────────────────────────\n * Two facts about this system make the stored status untrustworthy on its own:\n *\n * 1. NOTHING sweeps memberships to `expired`. There is no job. A member who\n * stopped paying in March keeps `status: 'active'` in the database all\n * year, so the term's END DATE is the only honest answer.\n * 2. NOTHING resumes a pause. `pausedTo` was written and then never read by\n * any code path, so a two-week pause locked a member out permanently and\n * the only way back was an owner noticing and flipping the roster switch\n * by hand.\n *\n * ── What is NOT re-derived ──────────────────────────────────────────────────\n * `pending`, `rejected`, `cancelled` and `expired` are all decisions a PERSON\n * made, and are returned untouched. `expired` matters most: an owner\n * deactivating a member sets exactly that, and re-deriving it back to `active`\n * because the term still has days left would silently overrule them.\n *\n * `today` is passed in rather than read from the clock, because the answer is\n * the GYM's day — see `gymDayOf` on the server and `istDayOf` on the client.\n * `YYYY-MM-DD` compares lexicographically, so these are exact calendar-day\n * comparisons with no instant arithmetic and no zone hidden inside.\n */\nexport function effectiveMembershipStatus(\n membership: MembershipStatusInput,\n today: string,\n): MembershipStatus {\n const { status, endDate, pausedFrom, pausedTo } = membership;\n\n // Decided by a person. Never second-guessed.\n if (\n status === 'pending' ||\n status === 'rejected' ||\n status === 'cancelled' ||\n status === 'expired' ||\n status === 'invited'\n ) {\n return status;\n }\n\n /**\n * A pause is a WINDOW, not a switch somebody has to remember to turn off.\n *\n * Reading it as a window is what makes the resume happen without a scheduled\n * job: before it, the plan runs; inside it, the member is out; after it, they\n * are simply back, on the end date the pause already pushed out for them.\n * A pause with no end recorded is treated as still running, because the safe\n * failure for a missing bound is to keep the member out rather than to let\n * an unbounded pause silently expire.\n */\n if (status === 'paused') {\n if (!pausedTo) return 'paused';\n const startedYet = !pausedFrom || today >= pausedFrom;\n if (startedYet && today <= pausedTo) return 'paused';\n }\n\n return endDate >= today ? 'active' : 'expired';\n}\n\n/** Can this member train today? The one question the door asks. */\nexport function isMembershipLive(membership: MembershipStatusInput, today: string): boolean {\n return effectiveMembershipStatus(membership, today) === 'active';\n}\n\n// ─── Lifecycle, as the UI says it ───────────────────────────────────────────\n\n/**\n * How close to `endDate` a live membership starts reading as \"expiring\".\n *\n * A PRESENTATION band and nothing more: the door never sees `expiring`, and a\n * member inside this window is fully `active` as far as access is concerned.\n * Distinct from `FEE_DUE_SOON_DAYS`, which is a much tighter window because it\n * gates a payment reminder rather than a colour.\n */\nexport const MEMBERSHIP_EXPIRING_WINDOW_DAYS = 30;\n\n/**\n * How far ahead the owner's home ALERT looks for memberships about to lapse.\n *\n * Deliberately much tighter than the pill's band, and the reason all three\n * windows are declared together here rather than as a `const` in whichever\n * file happened to need one:\n *\n * MEMBERSHIP_EXPIRING_WINDOW_DAYS 30 paints a pill amber; the roster's\n * \"Expiring Soon\" segment and filter\n * MEMBERSHIP_EXPIRY_ALERT_DAYS 7 the one-line banner on owner home,\n * which says \"expiring this week\"\n * FEE_DUE_SOON_DAYS 3 arms a payment reminder\n *\n * They are different NUMBERS because they drive different actions — a colour,\n * a banner and a message to a member should not all fire at once — but they\n * are the same QUESTION, so they belong side by side where the next person to\n * change one can see the other two.\n */\nexport const MEMBERSHIP_EXPIRY_ALERT_DAYS = 7;\n\n/**\n * The lifecycle states a SCREEN distinguishes.\n *\n * `cancelled` and `invited` are absent on purpose — both read as `expired` to\n * a viewer, and a pill that said \"Cancelled\" next to one that said \"Expired\"\n * would imply a difference the member cannot act on differently.\n */\nexport type DisplayMembershipStatus =\n | 'pending'\n | 'rejected'\n | 'active'\n | 'expiring'\n | 'paused'\n | 'expired';\n\n/**\n * The lifecycle, narrowed onto what a screen shows.\n *\n * Adds exactly one thing over `effectiveMembershipStatus`: the `expiring`\n * warning band. Everything else is a straight mapping, so a screen and the\n * door can never disagree about whether somebody is live — only about how\n * loudly to say it.\n */\nexport function displayMembershipStatus(\n membership: MembershipStatusInput,\n today: string,\n): DisplayMembershipStatus {\n const effective = effectiveMembershipStatus(membership, today);\n\n switch (effective) {\n case 'pending':\n return 'pending';\n case 'rejected':\n return 'rejected';\n case 'paused':\n return 'paused';\n case 'expired':\n case 'cancelled':\n return 'expired';\n default: {\n // Live. The only question left is how close the end is.\n const left = daysBetween(today, membership.endDate);\n return left <= MEMBERSHIP_EXPIRING_WINDOW_DAYS ? 'expiring' : 'active';\n }\n }\n}\n\n/**\n * Is this display state one where the member can still train?\n *\n * `expiring` is LIVE. It is a warning band, not a lifecycle state — the term\n * has days left and the door lets them in — so anything filtering or counting\n * \"active members\" has to include it. Written down here because that is\n * exactly the kind of rule each list would otherwise re-derive slightly\n * differently, and one of them would forget `expiring` and quietly hide every\n * member in their last month.\n */\nexport function isLiveDisplayStatus(status: DisplayMembershipStatus): boolean {\n return status === 'active' || status === 'expiring';\n}\n\n/**\n * The words on the lifecycle pill.\n *\n * Here rather than in the component because two surfaces calling the same\n * state different things is the failure this module exists to prevent.\n * \"Awaiting payment\" is deliberately not \"Pending\": pending is what the\n * database calls it, and the member wants to know who they are waiting on.\n */\nexport const MEMBERSHIP_STATUS_LABEL: Record<DisplayMembershipStatus, string> = {\n pending: 'Awaiting payment',\n rejected: 'Not confirmed',\n active: 'Active',\n expiring: 'Expiring',\n paused: 'Paused',\n expired: 'Expired',\n};\n\n// ═══════════════════════════════════════════════════════════════════════════\n// AXIS 2 — FEES: does this member owe money?\n// ═══════════════════════════════════════════════════════════════════════════\n\n/** How close to the end of a term a member starts reading as \"due soon\". */\nexport const FEE_DUE_SOON_DAYS = 3;\n\n/**\n * Where a member stands on money, as one phrase an owner can scan down a list.\n *\n * Every state exists because it needs a DIFFERENT action. This began life as\n * \"paid or not\", which collapsed five unrelated situations into one green\n * chip: a member with no plan, a member whose payment was waiting on the GYM,\n * and a member who had genuinely settled all read \"Paid\", so the roster asked\n * the owner to do nothing about two people who needed something done.\n *\n * overdue money owed, term already ended → chase them\n * unpaid money owed, term still running → chase them\n * rejected the gym turned their payment down → talk to them\n * pending they have paid, the GYM must confirm → owner confirms\n * lapsed term ended, nothing sold to replace → chase a renewal\n * due-soon ends within a few days → chase a renewal\n * no-plan never been sold a membership → owner assigns one\n * cancelled they ended it and left → nothing to collect\n * paid settled, with time left on the term → nothing\n */\nexport type FeeStatus =\n | 'overdue'\n | 'unpaid'\n | 'rejected'\n | 'pending'\n | 'lapsed'\n | 'due-soon'\n | 'no-plan'\n | 'cancelled'\n | 'paid';\n\n/**\n * What the fee question needs, as plain facts.\n *\n * Deliberately NOT a view model. This used to take the owner console's roster\n * card type, which is why nothing else in the product could ask the question —\n * including the backend, which computes two of these fields in the first place.\n */\nexport interface FeeStatusInput {\n /** The stored lifecycle, or null when no membership was ever sold. */\n membershipStatus: MembershipStatus | null;\n /** The server's own judgement: money outstanding AND the term already ended. */\n overdueDays?: number | null;\n /** Still owed on the current term, in whole rupees. */\n balanceDueInr: number;\n /** End of the current term, `YYYY-MM-DD`, or null when there is none. */\n expiresOn?: string | null;\n}\n\nexport interface FeeReading {\n status: FeeStatus;\n /**\n * Does this need the owner's eye? Drives the chip's colour.\n *\n * Deliberately NOT the same question as `chase`. A member waiting on the gym\n * to confirm their payment needs attention and must not be sent a reminder —\n * they have already paid, and the outstanding job is the owner's.\n */\n attention: boolean;\n /**\n * Is there something to remind this member ABOUT?\n *\n * Gates the reminder action. False wherever the next move belongs to the gym\n * rather than the member, which is what stops an owner messaging somebody\n * \"your fees are overdue\" while their payment sits in the gym's own\n * confirmation queue.\n */\n chase: boolean;\n /** Whole days until the term ends; negative once past, null when no term. */\n daysLeft: number | null;\n /** Days past the end of an unpaid term, when that is what happened. */\n overdueDays: number | null;\n /** Still owed, in whole rupees. Zero unless the status says otherwise. */\n amountDueInr: number;\n}\n\n/**\n * The fee reading for one member.\n *\n * Ordered by whose move it is and how hard the fact is. `overdueDays` is the\n * server's own judgement — money outstanding AND the term already ended — so\n * it is trusted rather than recomputed here.\n */\nexport function readFeeStatus(input: FeeStatusInput, today: string): FeeReading {\n const { membershipStatus, balanceDueInr } = input;\n const overdueDays = input.overdueDays ?? null;\n const base = { daysLeft: null, overdueDays: null, amountDueInr: 0 } as const;\n\n /**\n * Never sold a membership. Checked FIRST, because every money question below\n * presumes one exists — and a member with no plan cannot be behind on a fee\n * nobody has charged them.\n */\n if (membershipStatus === null) {\n return { ...base, status: 'no-plan', attention: true, chase: false };\n }\n\n /**\n * Waiting on the GYM, not on the member.\n *\n * Ahead of the balance check on purpose: a pending membership carries its\n * full price as outstanding, so this member would otherwise read as owing\n * money and be sent a payment reminder — for money they have already handed\n * over and the gym has simply not confirmed yet.\n */\n if (membershipStatus === 'pending') {\n return { ...base, status: 'pending', attention: true, chase: false };\n }\n\n /** The gym looked at their payment and turned it down. */\n if (membershipStatus === 'rejected') {\n return { ...base, status: 'rejected', attention: true, chase: true };\n }\n\n /**\n * They ended it themselves and left.\n *\n * Checked before the date logic, which would otherwise see a term end in the\n * past and read them as \"Renewal due\" — with a reminder ready to tell\n * somebody who deliberately quit that their membership has expired.\n */\n if (membershipStatus === 'cancelled') {\n return { ...base, status: 'cancelled', attention: false, chase: false };\n }\n\n // Late: the server has already established both halves of that.\n if (overdueDays && overdueDays > 0) {\n return {\n ...base,\n status: 'overdue',\n attention: true,\n chase: true,\n overdueDays,\n amountDueInr: balanceDueInr,\n };\n }\n\n /**\n * Owes money, but the term is still running.\n *\n * A real state and NOT the same as overdue: a gym that takes half now and\n * half on Friday has members here every week, and calling them late would be\n * wrong.\n */\n if (balanceDueInr > 0) {\n return { ...base, status: 'unpaid', attention: true, chase: true, amountDueInr: balanceDueInr };\n }\n\n const daysLeft = input.expiresOn ? daysBetween(today, input.expiresOn) : null;\n\n /**\n * The term ran out and nothing was sold to replace it.\n *\n * Nothing is OUTSTANDING — a term nobody assigned was never invoiced — so\n * this used to fall through and read \"Paid\", in green, for the member most\n * worth chasing on the whole roster.\n */\n if (daysLeft !== null && daysLeft < 0) {\n return { ...base, status: 'lapsed', attention: true, chase: true, daysLeft };\n }\n\n if (daysLeft !== null && daysLeft >= 0 && daysLeft <= FEE_DUE_SOON_DAYS) {\n return { ...base, status: 'due-soon', attention: true, chase: true, daysLeft };\n }\n\n /**\n * Settled, with time left on the term. The only genuinely quiet state, and\n * the only one that earns a green chip.\n */\n return { ...base, status: 'paid', attention: false, chase: false, daysLeft };\n}\n\n/**\n * The words on the fee chip.\n *\n * Functions rather than plain strings because three of these states have to\n * name a number, and the AMOUNT is formatted by whoever is rendering — a\n * client knows how to write ₹1,200 for its locale and this package does not.\n * The wording still lives here, so no surface can invent its own phrasing for\n * a state another surface already names.\n */\nexport const FEE_STATUS_LABEL: Record<FeeStatus, (reading: FeeReading, amount: string) => string> =\n {\n overdue: (r, amount) => `${amount} overdue ${plural(r.overdueDays ?? 0, 'day')}`,\n unpaid: (_r, amount) => `${amount} due`,\n rejected: () => 'Payment declined',\n pending: () => 'Confirm payment',\n lapsed: () => 'Renewal due',\n 'due-soon': (r) =>\n r.daysLeft === 0 ? 'Due today' : `Due in ${plural(r.daysLeft ?? 0, 'day')}`,\n 'no-plan': () => 'No plan yet',\n cancelled: () => 'Cancelled',\n paid: () => 'Paid',\n };\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Shared helpers\n// ═══════════════════════════════════════════════════════════════════════════\n\n/**\n * Whole days from `from` to `to`, both `YYYY-MM-DD`.\n *\n * Anchored at UTC midnight on BOTH sides, so the subtraction is whole days and\n * carries no zone of its own — the caller has already decided which day\n * \"today\" is, in the gym's timezone, and this must not second-guess that.\n */\nexport function daysBetween(from: string, to: string): number {\n const a = Date.parse(`${from}T00:00:00.000Z`);\n const b = Date.parse(`${to}T00:00:00.000Z`);\n if (Number.isNaN(a) || Number.isNaN(b)) return 0;\n return Math.round((b - a) / 86_400_000);\n}\n\n/** `2, 'day'` → `\"2 days\"`. */\nfunction plural(n: number, word: string): string {\n return n === 1 ? `${n} ${word}` : `${n} ${word}s`;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"membership-status.js","sourceRoot":"","sources":["../src/membership-status.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,8EAA8E;AAC9E,6CAA6C;AAC7C,8EAA8E;AAE9E;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,CAAC,IAAI,CAAC;IAC3C,SAAS;IACT,UAAU;IACV,SAAS;IACT,QAAQ;IACR,QAAQ;IACR,SAAS;IACT,WAAW;CACZ,CAAC,CAAC;AAWH;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,UAAU,yBAAyB,CACvC,UAAiC,EACjC,KAAa;IAEb,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,GAAG,UAAU,CAAC;IAE7D,6CAA6C;IAC7C,IACE,MAAM,KAAK,SAAS;QACpB,MAAM,KAAK,UAAU;QACrB,MAAM,KAAK,WAAW;QACtB,MAAM,KAAK,SAAS;QACpB,MAAM,KAAK,SAAS,EACpB,CAAC;QACD,OAAO,MAAM,CAAC;IAChB,CAAC;IAED;;;;;;;;;OASG;IACH,IAAI,MAAM,KAAK,QAAQ,EAAE,CAAC;QACxB,IAAI,CAAC,QAAQ;YAAE,OAAO,QAAQ,CAAC;QAC/B,MAAM,UAAU,GAAG,CAAC,UAAU,IAAI,KAAK,IAAI,UAAU,CAAC;QACtD,IAAI,UAAU,IAAI,KAAK,IAAI,QAAQ;YAAE,OAAO,QAAQ,CAAC;IACvD,CAAC;IAED,OAAO,OAAO,IAAI,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC;AACjD,CAAC;AAED,mEAAmE;AACnE,MAAM,UAAU,gBAAgB,CAAC,UAAiC,EAAE,KAAa;IAC/E,OAAO,yBAAyB,CAAC,UAAU,EAAE,KAAK,CAAC,KAAK,QAAQ,CAAC;AACnE,CAAC;AAED,+EAA+E;AAE/E;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,+BAA+B,GAAG,EAAE,CAAC;AAElD;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,MAAM,4BAA4B,GAAG,CAAC,CAAC;AAiB9C;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,uBAAuB,CACrC,UAAiC,EACjC,KAAa;IAEb,MAAM,SAAS,GAAG,yBAAyB,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;IAE/D,QAAQ,SAAS,EAAE,CAAC;QAClB,KAAK,SAAS;YACZ,OAAO,SAAS,CAAC;QACnB,KAAK,UAAU;YACb,OAAO,UAAU,CAAC;QACpB,KAAK,QAAQ;YACX,OAAO,QAAQ,CAAC;QAClB;;;;;WAKG;QACH,KAAK,SAAS,CAAC;QACf,KAAK,WAAW,CAAC;QACjB,KAAK,SAAS;YACZ,OAAO,SAAS,CAAC;QACnB,KAAK,QAAQ,CAAC,CAAC,CAAC;YACd,wDAAwD;YACxD,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,EAAE,UAAU,CAAC,OAAO,CAAC,CAAC;YACpD,OAAO,IAAI,IAAI,+BAA+B,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC;QACzE,CAAC;QACD,OAAO,CAAC,CAAC,CAAC;YACR,iEAAiE;YACjE,MAAM,SAAS,GAAU,SAAS,CAAC;YACnC,OAAO,SAAS,CAAC;QACnB,CAAC;IACH,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAsC;IACxE,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,UAAU,CAAC;AACtD,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAA4C;IAC9E,OAAO,EAAE,kBAAkB;IAC3B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,QAAQ;IAChB,QAAQ,EAAE,UAAU;IACpB,MAAM,EAAE,QAAQ;IAChB,OAAO,EAAE,SAAS;CACnB,CAAC;AAEF,8EAA8E;AAC9E,6CAA6C;AAC7C,8EAA8E;AAE9E,4EAA4E;AAC5E,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAC;AA6EnC;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,KAAqB,EAAE,KAAa;IAChE,MAAM,EAAE,gBAAgB,EAAE,aAAa,EAAE,GAAG,KAAK,CAAC;IAClD,MAAM,WAAW,GAAG,KAAK,CAAC,WAAW,IAAI,IAAI,CAAC;IAC9C,MAAM,IAAI,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,YAAY,EAAE,CAAC,EAAW,CAAC;IAE7E;;;;OAIG;IACH,IAAI,gBAAgB,KAAK,IAAI,EAAE,CAAC;QAC9B,OAAO,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IACvE,CAAC;IAED;;;;;;;OAOG;IACH,IAAI,gBAAgB,KAAK,SAAS,EAAE,CAAC;QACnC,OAAO,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IACvE,CAAC;IAED,0DAA0D;IAC1D,IAAI,gBAAgB,KAAK,UAAU,EAAE,CAAC;QACpC,OAAO,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;IACvE,CAAC;IAED;;;;;;OAMG;IACH,IAAI,gBAAgB,KAAK,WAAW,EAAE,CAAC;QACrC,OAAO,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,SAAS,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IAC1E,CAAC;IAED,gEAAgE;IAChE,IAAI,WAAW,IAAI,WAAW,GAAG,CAAC,EAAE,CAAC;QACnC,OAAO;YACL,GAAG,IAAI;YACP,MAAM,EAAE,SAAS;YACjB,SAAS,EAAE,IAAI;YACf,KAAK,EAAE,IAAI;YACX,WAAW;YACX,YAAY,EAAE,aAAa;SAC5B,CAAC;IACJ,CAAC;IAED;;;;;;OAMG;IACH,IAAI,aAAa,GAAG,CAAC,EAAE,CAAC;QACtB,OAAO,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,YAAY,EAAE,aAAa,EAAE,CAAC;IAClG,CAAC;IAED,MAAM,QAAQ,GAAG,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,KAAK,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAE9E;;;;;;OAMG;IACH,IAAI,QAAQ,KAAK,IAAI,IAAI,QAAQ,GAAG,CAAC,EAAE,CAAC;QACtC,OAAO,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;IAC/E,CAAC;IAED,IAAI,QAAQ,KAAK,IAAI,IAAI,QAAQ,IAAI,CAAC,IAAI,QAAQ,IAAI,iBAAiB,EAAE,CAAC;QACxE,OAAO,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;IACjF,CAAC;IAED;;;OAGG;IACH,OAAO,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC;AAC/E,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAC3B;IACE,OAAO,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,GAAG,MAAM,YAAY,MAAM,CAAC,CAAC,CAAC,WAAW,IAAI,CAAC,EAAE,KAAK,CAAC,EAAE;IAChF,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,CAAC,GAAG,MAAM,MAAM;IACvC,QAAQ,EAAE,GAAG,EAAE,CAAC,kBAAkB;IAClC,OAAO,EAAE,GAAG,EAAE,CAAC,iBAAiB;IAChC,MAAM,EAAE,GAAG,EAAE,CAAC,aAAa;IAC3B,UAAU,EAAE,CAAC,CAAC,EAAE,EAAE,CAChB,CAAC,CAAC,QAAQ,KAAK,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,UAAU,MAAM,CAAC,CAAC,CAAC,QAAQ,IAAI,CAAC,EAAE,KAAK,CAAC,EAAE;IAC7E,SAAS,EAAE,GAAG,EAAE,CAAC,aAAa;IAC9B,SAAS,EAAE,GAAG,EAAE,CAAC,WAAW;IAC5B,IAAI,EAAE,GAAG,EAAE,CAAC,MAAM;CACnB,CAAC;AAEJ,8EAA8E;AAC9E,iBAAiB;AACjB,8EAA8E;AAE9E;;;;;;GAMG;AACH,MAAM,UAAU,WAAW,CAAC,IAAY,EAAE,EAAU;IAClD,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,IAAI,gBAAgB,CAAC,CAAC;IAC9C,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,gBAAgB,CAAC,CAAC;IAC5C,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC;IACjD,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,UAAU,CAAC,CAAC;AAC1C,CAAC;AAED,+BAA+B;AAC/B,SAAS,MAAM,CAAC,CAAS,EAAE,IAAY;IACrC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC;AACpD,CAAC","sourcesContent":["/**\n * gymmonk-schema — Membership status, all of it, in one place\n * ============================================================\n * Everything the product knows about \"where does this member stand\" lives\n * here: the stored lifecycle, the effective one, the words shown on screen,\n * the money reading, and the three thresholds that decide \"soon\".\n *\n * ── Why this module exists ──────────────────────────────────────────────────\n * These facts were spread across five files in two repos. The stored enum and\n * the effective rule were in `membership.ts`; the DISPLAY vocabulary and its\n * derivation were in the web client's `shared/lib/format.ts`, out of the\n * backend's reach; the labels were inline in a pill component; the fee\n * vocabulary was in an owner-console file and took a web VIEW TYPE as its\n * input, so nothing else could call it. Two of the web's own feature folders\n * had also declared `MembershipStatus` as an alias of the DISPLAY type — the\n * same name as the stored one, meaning something different, in the same\n * codebase.\n *\n * ── There are TWO axes, and collapsing them would lose information ──────────\n * They are correlated, not identical, and they answer different questions:\n *\n * LIFECYCLE can this person train today? (the door asks this)\n * FEES does this person owe money? (the owner's roster asks this)\n *\n * A member part-way through a term they have half-paid is `active` on the\n * first axis and `unpaid` on the second, and gyms take part payments every\n * week. One flat enum would have to pick a side.\n *\n * The axes relate like this, and `readFeeStatus` encodes exactly this table:\n *\n * lifecycle money owed → fee status\n * active no → paid (or due-soon near the end of the term)\n * active yes → unpaid\n * expired yes → overdue\n * expired no → lapsed\n * pending n/a → pending (the GYM owes the next move)\n * none n/a → no-plan (the OWNER owes the next move)\n *\n * ── What is NOT here ────────────────────────────────────────────────────────\n * Colours. A tone is a Tailwind class, it belongs to whichever client is\n * painting, and there is exactly one consumer of each. The words live here\n * because two surfaces must never call the same state different things.\n *\n * @module gymmonk-schema/membership-status\n */\n\nimport { z } from 'zod';\n\n// ═══════════════════════════════════════════════════════════════════════════\n// AXIS 1 — LIFECYCLE: can this member train?\n// ═══════════════════════════════════════════════════════════════════════════\n\n/**\n * The STORED lifecycle column.\n *\n * `pending` is where every membership STARTS, whoever created it. Choosing a\n * plan is not the same event as paying for one, and the two used to be welded\n * together: the moment a member tapped a plan it went live, and the moment an\n * owner assigned one at the desk it went live, whether or not any money had\n * changed hands. A gym then had no way to tell a paid member from an optimistic\n * one, and no lever to withhold access from the second.\n *\n * The only transition out of `pending` is the gym marking the payment done.\n * That is deliberately a HUMAN act by the owner or a manager rather than a\n * gateway callback: these gyms take cash at the desk and UPI transfers on a\n * phone, so the person who knows the money arrived is the person at the gym.\n *\n * `rejected` is the other way out, for a screenshot that shows nothing, the\n * wrong amount, or a transfer that never landed. It is a dead end — the member\n * requests again rather than the gym reopening a closed decision.\n *\n * `invited` predates all of this and nothing writes it. Left in the union\n * because removing a value from a stored enum is not worth a migration, and old\n * rows still have to parse.\n *\n * `expiring` is NOT here — it is derived from `endDate` at the edge. See\n * `DisplayMembershipStatus`.\n */\nexport const membershipStatusSchema = z.enum([\n 'pending',\n 'rejected',\n 'invited',\n 'active',\n 'paused',\n 'expired',\n 'cancelled',\n]);\nexport type MembershipStatus = z.infer<typeof membershipStatusSchema>;\n\n/** The fields any status question needs. Deliberately not a full membership. */\nexport interface MembershipStatusInput {\n status: MembershipStatus;\n endDate: string;\n pausedFrom?: string | null;\n pausedTo?: string | null;\n}\n\n/**\n * What a membership IS right now, as against what was last written to its\n * `status` column.\n *\n * ── Why this has to exist ───────────────────────────────────────────────────\n * Two facts about this system make the stored status untrustworthy on its own:\n *\n * 1. NOTHING sweeps memberships to `expired`. There is no job. A member who\n * stopped paying in March keeps `status: 'active'` in the database all\n * year, so the term's END DATE is the only honest answer.\n * 2. NOTHING resumes a pause. `pausedTo` was written and then never read by\n * any code path, so a two-week pause locked a member out permanently and\n * the only way back was an owner noticing and flipping the roster switch\n * by hand.\n *\n * ── What is NOT re-derived ──────────────────────────────────────────────────\n * `pending`, `rejected`, `cancelled` and `expired` are all decisions a PERSON\n * made, and are returned untouched. `expired` matters most: an owner\n * deactivating a member sets exactly that, and re-deriving it back to `active`\n * because the term still has days left would silently overrule them.\n *\n * `today` is passed in rather than read from the clock, because the answer is\n * the GYM's day — see `gymDayOf` on the server and `istDayOf` on the client.\n * `YYYY-MM-DD` compares lexicographically, so these are exact calendar-day\n * comparisons with no instant arithmetic and no zone hidden inside.\n */\nexport function effectiveMembershipStatus(\n membership: MembershipStatusInput,\n today: string,\n): MembershipStatus {\n const { status, endDate, pausedFrom, pausedTo } = membership;\n\n // Decided by a person. Never second-guessed.\n if (\n status === 'pending' ||\n status === 'rejected' ||\n status === 'cancelled' ||\n status === 'expired' ||\n status === 'invited'\n ) {\n return status;\n }\n\n /**\n * A pause is a WINDOW, not a switch somebody has to remember to turn off.\n *\n * Reading it as a window is what makes the resume happen without a scheduled\n * job: before it, the plan runs; inside it, the member is out; after it, they\n * are simply back, on the end date the pause already pushed out for them.\n * A pause with no end recorded is treated as still running, because the safe\n * failure for a missing bound is to keep the member out rather than to let\n * an unbounded pause silently expire.\n */\n if (status === 'paused') {\n if (!pausedTo) return 'paused';\n const startedYet = !pausedFrom || today >= pausedFrom;\n if (startedYet && today <= pausedTo) return 'paused';\n }\n\n return endDate >= today ? 'active' : 'expired';\n}\n\n/** Can this member train today? The one question the door asks. */\nexport function isMembershipLive(membership: MembershipStatusInput, today: string): boolean {\n return effectiveMembershipStatus(membership, today) === 'active';\n}\n\n// ─── Lifecycle, as the UI says it ───────────────────────────────────────────\n\n/**\n * How close to `endDate` a live membership starts reading as \"expiring\".\n *\n * A PRESENTATION band and nothing more: the door never sees `expiring`, and a\n * member inside this window is fully `active` as far as access is concerned.\n * Distinct from `FEE_DUE_SOON_DAYS`, which is a much tighter window because it\n * gates a payment reminder rather than a colour.\n */\nexport const MEMBERSHIP_EXPIRING_WINDOW_DAYS = 30;\n\n/**\n * How far ahead the owner's home ALERT looks for memberships about to lapse.\n *\n * Deliberately much tighter than the pill's band, and the reason all three\n * windows are declared together here rather than as a `const` in whichever\n * file happened to need one:\n *\n * MEMBERSHIP_EXPIRING_WINDOW_DAYS 30 paints a pill amber; the roster's\n * \"Expiring Soon\" segment and filter\n * MEMBERSHIP_EXPIRY_ALERT_DAYS 7 the one-line banner on owner home,\n * which says \"expiring this week\"\n * FEE_DUE_SOON_DAYS 3 arms a payment reminder\n *\n * They are different NUMBERS because they drive different actions — a colour,\n * a banner and a message to a member should not all fire at once — but they\n * are the same QUESTION, so they belong side by side where the next person to\n * change one can see the other two.\n */\nexport const MEMBERSHIP_EXPIRY_ALERT_DAYS = 7;\n\n/**\n * The lifecycle states a SCREEN distinguishes.\n *\n * `cancelled` and `invited` are absent on purpose — both read as `expired` to\n * a viewer, and a pill that said \"Cancelled\" next to one that said \"Expired\"\n * would imply a difference the member cannot act on differently.\n */\nexport type DisplayMembershipStatus =\n | 'pending'\n | 'rejected'\n | 'active'\n | 'expiring'\n | 'paused'\n | 'expired';\n\n/**\n * The lifecycle, narrowed onto what a screen shows.\n *\n * Adds exactly one thing over `effectiveMembershipStatus`: the `expiring`\n * warning band. Everything else is a straight mapping, so a screen and the\n * door can never disagree about whether somebody is live — only about how\n * loudly to say it.\n *\n * EXHAUSTIVE over `MembershipStatus`, and that is load-bearing rather than\n * tidy. This used to end in a `default` branch that meant \"live\", so every\n * stored value nobody had thought about was adopted as `active` on sight:\n * `invited` reached a screen as a running membership while `isMembershipLive`\n * turned the same person away at the door, and the roster's active FILTER\n * counted them in while the header count beside it — which reads the same\n * question out of Mongo — left them out. Naming every case makes adding one\n * to the enum a build failure until somebody decides what a screen calls it.\n */\nexport function displayMembershipStatus(\n membership: MembershipStatusInput,\n today: string,\n): DisplayMembershipStatus {\n const effective = effectiveMembershipStatus(membership, today);\n\n switch (effective) {\n case 'pending':\n return 'pending';\n case 'rejected':\n return 'rejected';\n case 'paused':\n return 'paused';\n /**\n * All three are over, and a viewer can do exactly one thing about any of\n * them: buy again. `invited` is here because it is not live at the door\n * either — a membership nobody claimed has never run — and a screen\n * saying otherwise would be the screen disagreeing with the lock.\n */\n case 'expired':\n case 'cancelled':\n case 'invited':\n return 'expired';\n case 'active': {\n // Live. The only question left is how close the end is.\n const left = daysBetween(today, membership.endDate);\n return left <= MEMBERSHIP_EXPIRING_WINDOW_DAYS ? 'expiring' : 'active';\n }\n default: {\n // Unreachable today, and a COMPILE error the day the enum grows.\n const unhandled: never = effective;\n return unhandled;\n }\n }\n}\n\n/**\n * Is this display state one where the member can still train?\n *\n * `expiring` is LIVE. It is a warning band, not a lifecycle state — the term\n * has days left and the door lets them in — so anything filtering or counting\n * \"active members\" has to include it. Written down here because that is\n * exactly the kind of rule each list would otherwise re-derive slightly\n * differently, and one of them would forget `expiring` and quietly hide every\n * member in their last month.\n *\n * `null` means no membership was ever sold, which is not a lifecycle state and\n * is certainly not live. Accepted here rather than pushed back onto callers so\n * that a roster row with no plan is one `if` for everybody instead of a\n * judgement each list makes for itself.\n */\nexport function isLiveDisplayStatus(status: DisplayMembershipStatus | null): boolean {\n return status === 'active' || status === 'expiring';\n}\n\n/**\n * The words on the lifecycle pill.\n *\n * Here rather than in the component because two surfaces calling the same\n * state different things is the failure this module exists to prevent.\n * \"Awaiting payment\" is deliberately not \"Pending\": pending is what the\n * database calls it, and the member wants to know who they are waiting on.\n */\nexport const MEMBERSHIP_STATUS_LABEL: Record<DisplayMembershipStatus, string> = {\n pending: 'Awaiting payment',\n rejected: 'Not confirmed',\n active: 'Active',\n expiring: 'Expiring',\n paused: 'Paused',\n expired: 'Expired',\n};\n\n// ═══════════════════════════════════════════════════════════════════════════\n// AXIS 2 — FEES: does this member owe money?\n// ═══════════════════════════════════════════════════════════════════════════\n\n/** How close to the end of a term a member starts reading as \"due soon\". */\nexport const FEE_DUE_SOON_DAYS = 3;\n\n/**\n * Where a member stands on money, as one phrase an owner can scan down a list.\n *\n * Every state exists because it needs a DIFFERENT action. This began life as\n * \"paid or not\", which collapsed five unrelated situations into one green\n * chip: a member with no plan, a member whose payment was waiting on the GYM,\n * and a member who had genuinely settled all read \"Paid\", so the roster asked\n * the owner to do nothing about two people who needed something done.\n *\n * overdue money owed, term already ended → chase them\n * unpaid money owed, term still running → chase them\n * rejected the gym turned their payment down → talk to them\n * pending they have paid, the GYM must confirm → owner confirms\n * lapsed term ended, nothing sold to replace → chase a renewal\n * due-soon ends within a few days → chase a renewal\n * no-plan never been sold a membership → owner assigns one\n * cancelled they ended it and left → nothing to collect\n * paid settled, with time left on the term → nothing\n */\nexport type FeeStatus =\n | 'overdue'\n | 'unpaid'\n | 'rejected'\n | 'pending'\n | 'lapsed'\n | 'due-soon'\n | 'no-plan'\n | 'cancelled'\n | 'paid';\n\n/**\n * What the fee question needs, as plain facts.\n *\n * Deliberately NOT a view model. This used to take the owner console's roster\n * card type, which is why nothing else in the product could ask the question —\n * including the backend, which computes two of these fields in the first place.\n */\nexport interface FeeStatusInput {\n /** The stored lifecycle, or null when no membership was ever sold. */\n membershipStatus: MembershipStatus | null;\n /** The server's own judgement: money outstanding AND the term already ended. */\n overdueDays?: number | null;\n /** Still owed on the current term, in whole rupees. */\n balanceDueInr: number;\n /** End of the current term, `YYYY-MM-DD`, or null when there is none. */\n expiresOn?: string | null;\n}\n\nexport interface FeeReading {\n status: FeeStatus;\n /**\n * Does this need the owner's eye? Drives the chip's colour.\n *\n * Deliberately NOT the same question as `chase`. A member waiting on the gym\n * to confirm their payment needs attention and must not be sent a reminder —\n * they have already paid, and the outstanding job is the owner's.\n */\n attention: boolean;\n /**\n * Is there something to remind this member ABOUT?\n *\n * Gates the reminder action. False wherever the next move belongs to the gym\n * rather than the member, which is what stops an owner messaging somebody\n * \"your fees are overdue\" while their payment sits in the gym's own\n * confirmation queue.\n */\n chase: boolean;\n /** Whole days until the term ends; negative once past, null when no term. */\n daysLeft: number | null;\n /** Days past the end of an unpaid term, when that is what happened. */\n overdueDays: number | null;\n /** Still owed, in whole rupees. Zero unless the status says otherwise. */\n amountDueInr: number;\n}\n\n/**\n * The fee reading for one member.\n *\n * Ordered by whose move it is and how hard the fact is. `overdueDays` is the\n * server's own judgement — money outstanding AND the term already ended — so\n * it is trusted rather than recomputed here.\n */\nexport function readFeeStatus(input: FeeStatusInput, today: string): FeeReading {\n const { membershipStatus, balanceDueInr } = input;\n const overdueDays = input.overdueDays ?? null;\n const base = { daysLeft: null, overdueDays: null, amountDueInr: 0 } as const;\n\n /**\n * Never sold a membership. Checked FIRST, because every money question below\n * presumes one exists — and a member with no plan cannot be behind on a fee\n * nobody has charged them.\n */\n if (membershipStatus === null) {\n return { ...base, status: 'no-plan', attention: true, chase: false };\n }\n\n /**\n * Waiting on the GYM, not on the member.\n *\n * Ahead of the balance check on purpose: a pending membership carries its\n * full price as outstanding, so this member would otherwise read as owing\n * money and be sent a payment reminder — for money they have already handed\n * over and the gym has simply not confirmed yet.\n */\n if (membershipStatus === 'pending') {\n return { ...base, status: 'pending', attention: true, chase: false };\n }\n\n /** The gym looked at their payment and turned it down. */\n if (membershipStatus === 'rejected') {\n return { ...base, status: 'rejected', attention: true, chase: true };\n }\n\n /**\n * They ended it themselves and left.\n *\n * Checked before the date logic, which would otherwise see a term end in the\n * past and read them as \"Renewal due\" — with a reminder ready to tell\n * somebody who deliberately quit that their membership has expired.\n */\n if (membershipStatus === 'cancelled') {\n return { ...base, status: 'cancelled', attention: false, chase: false };\n }\n\n // Late: the server has already established both halves of that.\n if (overdueDays && overdueDays > 0) {\n return {\n ...base,\n status: 'overdue',\n attention: true,\n chase: true,\n overdueDays,\n amountDueInr: balanceDueInr,\n };\n }\n\n /**\n * Owes money, but the term is still running.\n *\n * A real state and NOT the same as overdue: a gym that takes half now and\n * half on Friday has members here every week, and calling them late would be\n * wrong.\n */\n if (balanceDueInr > 0) {\n return { ...base, status: 'unpaid', attention: true, chase: true, amountDueInr: balanceDueInr };\n }\n\n const daysLeft = input.expiresOn ? daysBetween(today, input.expiresOn) : null;\n\n /**\n * The term ran out and nothing was sold to replace it.\n *\n * Nothing is OUTSTANDING — a term nobody assigned was never invoiced — so\n * this used to fall through and read \"Paid\", in green, for the member most\n * worth chasing on the whole roster.\n */\n if (daysLeft !== null && daysLeft < 0) {\n return { ...base, status: 'lapsed', attention: true, chase: true, daysLeft };\n }\n\n if (daysLeft !== null && daysLeft >= 0 && daysLeft <= FEE_DUE_SOON_DAYS) {\n return { ...base, status: 'due-soon', attention: true, chase: true, daysLeft };\n }\n\n /**\n * Settled, with time left on the term. The only genuinely quiet state, and\n * the only one that earns a green chip.\n */\n return { ...base, status: 'paid', attention: false, chase: false, daysLeft };\n}\n\n/**\n * The words on the fee chip.\n *\n * Functions rather than plain strings because three of these states have to\n * name a number, and the AMOUNT is formatted by whoever is rendering — a\n * client knows how to write ₹1,200 for its locale and this package does not.\n * The wording still lives here, so no surface can invent its own phrasing for\n * a state another surface already names.\n */\nexport const FEE_STATUS_LABEL: Record<FeeStatus, (reading: FeeReading, amount: string) => string> =\n {\n overdue: (r, amount) => `${amount} overdue ${plural(r.overdueDays ?? 0, 'day')}`,\n unpaid: (_r, amount) => `${amount} due`,\n rejected: () => 'Payment declined',\n pending: () => 'Confirm payment',\n lapsed: () => 'Renewal due',\n 'due-soon': (r) =>\n r.daysLeft === 0 ? 'Due today' : `Due in ${plural(r.daysLeft ?? 0, 'day')}`,\n 'no-plan': () => 'No plan yet',\n cancelled: () => 'Cancelled',\n paid: () => 'Paid',\n };\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Shared helpers\n// ═══════════════════════════════════════════════════════════════════════════\n\n/**\n * Whole days from `from` to `to`, both `YYYY-MM-DD`.\n *\n * Anchored at UTC midnight on BOTH sides, so the subtraction is whole days and\n * carries no zone of its own — the caller has already decided which day\n * \"today\" is, in the gym's timezone, and this must not second-guess that.\n */\nexport function daysBetween(from: string, to: string): number {\n const a = Date.parse(`${from}T00:00:00.000Z`);\n const b = Date.parse(`${to}T00:00:00.000Z`);\n if (Number.isNaN(a) || Number.isNaN(b)) return 0;\n return Math.round((b - a) / 86_400_000);\n}\n\n/** `2, 'day'` → `\"2 days\"`. */\nfunction plural(n: number, word: string): string {\n return n === 1 ? `${n} ${word}` : `${n} ${word}s`;\n}\n"]}
|
package/dist/session.d.ts
CHANGED
|
@@ -177,6 +177,7 @@ export declare const attendanceEntrySchema: z.ZodObject<{
|
|
|
177
177
|
}>>;
|
|
178
178
|
checkInBy: z.ZodNullable<z.ZodString>;
|
|
179
179
|
checkOutBy: z.ZodNullable<z.ZodString>;
|
|
180
|
+
atGym: z.ZodNullable<z.ZodBoolean>;
|
|
180
181
|
}, z.core.$strip>;
|
|
181
182
|
export type AttendanceEntry = z.infer<typeof attendanceEntrySchema>;
|
|
182
183
|
export declare const attendanceQuerySchema: z.ZodObject<{
|
package/dist/session.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAcxB,eAAO,MAAM,mBAAmB;;;;EAA0C,CAAC;AAC3E,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAIhE;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,sBAAsB;;;;iBAMjC,CAAC;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE,+CAA+C;AAC/C,eAAO,MAAM,cAAc,EAAE,gBAI5B,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,gBAAgB,EAAE,GAAG,GAAE,IAAiB,GAAG,MAAM,CAO/F;AAID,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAY/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAIlE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB;;;;;iBAOQ,CAAC;AACvC,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAE5D;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;iBAE7B,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D,eAAO,MAAM,0BAA0B;;;iBAGrC,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAC;AAE9E;;;;;;;GAOG;AACH,eAAO,MAAM,yBAAyB;;;;;;iBAEpC,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,yBAAyB,CAAC,CAAC;AAI5E,eAAO,MAAM,sBAAsB;;;;EAAwC,CAAC;AAC5E,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,sBAAsB;;;;EAAqC,CAAC;AACzE,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC,eAAO,MAAM,qBAAqB
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAcxB,eAAO,MAAM,mBAAmB;;;;EAA0C,CAAC;AAC3E,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAIhE;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,sBAAsB;;;;iBAMjC,CAAC;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE,+CAA+C;AAC/C,eAAO,MAAM,cAAc,EAAE,gBAI5B,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,gBAAgB,EAAE,GAAG,GAAE,IAAiB,GAAG,MAAM,CAO/F;AAID,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAY/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAIlE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB;;;;;iBAOQ,CAAC;AACvC,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAE5D;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;iBAE7B,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D,eAAO,MAAM,0BAA0B;;;iBAGrC,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAC;AAE9E;;;;;;;GAOG;AACH,eAAO,MAAM,yBAAyB;;;;;;iBAEpC,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,yBAAyB,CAAC,CAAC;AAI5E,eAAO,MAAM,sBAAsB;;;;EAAwC,CAAC;AAC5E,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,sBAAsB;;;;EAAqC,CAAC;AACzE,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;iBA0ChC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAEpE,eAAO,MAAM,qBAAqB;;;;;;iBAIhC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAEpE,sEAAsE;AACtE,eAAO,MAAM,wBAAwB;;;;;;;;;;;iBAOnC,CAAC;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC"}
|
package/dist/session.js
CHANGED
|
@@ -160,6 +160,29 @@ export const attendanceEntrySchema = z.object({
|
|
|
160
160
|
*/
|
|
161
161
|
checkInBy: z.string().nullable(),
|
|
162
162
|
checkOutBy: z.string().nullable(),
|
|
163
|
+
/**
|
|
164
|
+
* Was the member at the gym when they scanned? THREE answers, never two.
|
|
165
|
+
*
|
|
166
|
+
* `true` we measured, and they were within the gym's radius.
|
|
167
|
+
* `false` we measured, and they were not.
|
|
168
|
+
* `null` we could not measure — nobody pinned the gym, or the phone would
|
|
169
|
+
* not give a fix.
|
|
170
|
+
*
|
|
171
|
+
* Nullable is the whole point. A gym that has never been pinned and a member
|
|
172
|
+
* standing at the front desk must not read the same, and collapsing
|
|
173
|
+
* "unknown" into either answer invents a fact: into `true` it launders a
|
|
174
|
+
* guess into a verification, into `false` it accuses somebody over a
|
|
175
|
+
* measurement nobody took.
|
|
176
|
+
*
|
|
177
|
+
* ── This never refuses a check-in ───────────────────────────────────────────
|
|
178
|
+
* It is a REPORT, not a gate, and the ONLY thing location does in this
|
|
179
|
+
* product. Scanning always succeeds. A phone indoors, behind a concrete wall
|
|
180
|
+
* and a floor of equipment, routinely reports a fix 100-150 m out — so
|
|
181
|
+
* refusing on this locked members out of the gym they pay for while catching
|
|
182
|
+
* nobody, since spoofing a coordinate is cheaper than the walk it was meant
|
|
183
|
+
* to enforce. The owner sees the flag and decides.
|
|
184
|
+
*/
|
|
185
|
+
atGym: z.boolean().nullable(),
|
|
163
186
|
});
|
|
164
187
|
export const attendanceQuerySchema = paginationSchema.extend({
|
|
165
188
|
userId: objectIdSchema.optional(),
|
package/dist/session.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — Workout sessions, check-in & attendance\n * ========================================================\n * A member checks in to start a live session, marks exercises done, and checks\n * out. Sessions roll up into attendance records the owner can review, and can\n * also be recorded directly by an owner (manual attendance marking).\n *\n * @module gymmonk-schema/session\n */\n\nimport { z } from 'zod';\nimport { checkInCodeSchema } from './check-in.js';\nimport {\n isoDateSchema,\n isoDateTimeSchema,\n objectIdSchema,\n optionalTrimmedString,\n paginationSchema,\n} from './common.js';\nimport { checkInGeoInputSchema } from './location.js';\nimport { weekdaySchema } from './shared.js';\n\n// ─── Live session widget status ──────────────────────────────────────────────\n\nexport const sessionStatusSchema = z.enum(['idle', 'active', 'completed']);\nexport type SessionStatus = z.infer<typeof sessionStatusSchema>;\n\n// ─── The set stopwatch ───────────────────────────────────────────────────────\n\n/**\n * The member's own stopwatch, which is NOT the session clock.\n *\n * Home shows two running times and they measure different things. The session\n * clock is \"how long have you been in the gym\" — it runs from `checkInAt`,\n * cannot be paused, and is derived rather than stored. This one is \"how long\n * has this set taken\", and the member starts, pauses and resets it freely.\n *\n * Stored as ACCUMULATED + STARTED-AT rather than as a running total, because a\n * total would need the server to tick. Elapsed is\n * `accumulatedSec + (running ? now - startedAt : 0)`, so a paused stopwatch is\n * a plain number, a running one survives a refresh or a dead battery, and no\n * job has to write to the database once a second.\n *\n * `startedAt` is null exactly when `running` is false; the pair is a small\n * state machine, and the backend is the only writer.\n */\nexport const sessionStopwatchSchema = z.object({\n running: z.boolean(),\n /** Seconds banked by previous runs, excluding any run in progress. */\n accumulatedSec: z.number().int().nonnegative(),\n /** When the current run began. Null while paused. */\n startedAt: isoDateTimeSchema.nullable(),\n});\nexport type SessionStopwatch = z.infer<typeof sessionStopwatchSchema>;\n\n/** A stopwatch that has never been started. */\nexport const IDLE_STOPWATCH: SessionStopwatch = {\n running: false,\n accumulatedSec: 0,\n startedAt: null,\n};\n\n/**\n * Resolve a stopwatch to whole seconds elapsed.\n *\n * Shared rather than reimplemented on each side: the backend needs it to bank\n * time on pause, and the client needs it to render every tick. Two copies of\n * this arithmetic would drift the moment one of them forgot the running run.\n */\nexport function stopwatchElapsedSec(stopwatch: SessionStopwatch, now: Date = new Date()): number {\n if (!stopwatch.running || !stopwatch.startedAt) return stopwatch.accumulatedSec;\n const started = Date.parse(stopwatch.startedAt);\n if (Number.isNaN(started)) return stopwatch.accumulatedSec;\n // Floored at the banked value: a client clock behind the server's would\n // otherwise make the number run backwards.\n return stopwatch.accumulatedSec + Math.max(0, Math.floor((now.getTime() - started) / 1000));\n}\n\n// ─── Session entity ──────────────────────────────────────────────────────────\n\nexport const workoutSessionSchema = z.object({\n id: objectIdSchema,\n userId: objectIdSchema,\n centerId: objectIdSchema,\n status: sessionStatusSchema,\n checkInAt: isoDateTimeSchema,\n checkOutAt: isoDateTimeSchema.nullable(),\n /** The plan weekday being trained, if the member is on a plan. */\n planWeekday: weekdaySchema.nullable(),\n completedExerciseIds: z.array(objectIdSchema),\n totalExercises: z.number().int().nonnegative(),\n stopwatch: sessionStopwatchSchema,\n});\nexport type WorkoutSession = z.infer<typeof workoutSessionSchema>;\n\n// ─── Session mutations ───────────────────────────────────────────────────────\n\n/**\n * Check in by scanning the gym's QR poster.\n *\n * `code` is REQUIRED and is the whole point: the center is resolved FROM the\n * scanned code, never from a client-supplied `centerId`. An earlier version of\n * this body took an optional `centerId`, which meant any authenticated member\n * could mark themselves present from their sofa. Presence has to be proven by\n * being close enough to the poster to photograph it.\n *\n * Staff marking someone present by hand is a different, authorised path —\n * `markAttendanceBodySchema` below.\n */\nexport const checkInBodySchema = z\n .object({\n code: checkInCodeSchema,\n })\n // Where the scanning device thought it was. All three optional — see\n // `checkInGeoInputSchema`. The server measures this against the gym's pin and\n // treats a missing fix as unverifiable rather than as a failure.\n .extend(checkInGeoInputSchema.shape);\nexport type CheckInBody = z.infer<typeof checkInBodySchema>;\n\n/**\n * Check out — the same poster, scanned again. Verified against the session's\n * OWN center, so a member cannot close a session by scanning a different gym.\n */\nexport const checkOutBodySchema = z.object({\n code: checkInCodeSchema,\n});\nexport type CheckOutBody = z.infer<typeof checkOutBodySchema>;\n\nexport const markExerciseDoneBodySchema = z.object({\n exerciseId: objectIdSchema,\n done: z.boolean(),\n});\nexport type MarkExerciseDoneBody = z.infer<typeof markExerciseDoneBodySchema>;\n\n/**\n * Drive the set stopwatch.\n *\n * An ACTION, not a state patch. The client says what the member pressed and the\n * server computes the resulting `{ running, accumulatedSec, startedAt }` — which\n * keeps the banking arithmetic in one place and means a client cannot post an\n * arbitrary elapsed time to inflate a workout.\n */\nexport const stopwatchActionBodySchema = z.object({\n action: z.enum(['start', 'pause', 'reset']),\n});\nexport type StopwatchActionBody = z.infer<typeof stopwatchActionBodySchema>;\n\n// ─── Attendance ──────────────────────────────────────────────────────────────\n\nexport const attendanceStatusSchema = z.enum(['present', 'absent', 'rest']);\nexport type AttendanceStatus = z.infer<typeof attendanceStatusSchema>;\n\n/**\n * How an attendance mark came to be recorded.\n *\n * `scan` — the member scanned the gym's QR poster themselves.\n * `manual` — a person put it there: an owner, manager or front desk.\n * `auto` — nobody did. The member never scanned out and the session hit its\n * cap, so the system closed it (see `AUTO_CHECK_OUT_HOURS`).\n *\n * Recorded per SIDE, because the two halves of a visit routinely differ: the\n * common case is a member who scans in and is closed out automatically. An\n * attendance report that cannot tell a scanned departure from an assumed one is\n * reporting a guess as a measurement.\n */\nexport const attendanceSourceSchema = z.enum(['scan', 'manual', 'auto']);\nexport type AttendanceSource = z.infer<typeof attendanceSourceSchema>;\n\n/**\n * How long a session may stay open before it is closed for the member.\n *\n * Shared with the client so the member's screen can say when their session will\n * end, rather than having the number live only in the server that enforces it.\n */\nexport const AUTO_CHECK_OUT_HOURS = 3;\n\nexport const attendanceEntrySchema = z.object({\n date: isoDateSchema,\n checkInAt: isoDateTimeSchema.nullable(),\n checkOutAt: isoDateTimeSchema.nullable(),\n status: attendanceStatusSchema,\n /** How each half was recorded. Null where that half has not happened. */\n checkInSource: attendanceSourceSchema.nullable(),\n checkOutSource: attendanceSourceSchema.nullable(),\n /**\n * WHO marked it, when a person did — a name, snapshotted at the time.\n *\n * Null for `scan` and `auto`, which have no author. A snapshot rather than a\n * live lookup because this is a record of what happened: a manager who later\n * leaves, or changes their name, must not silently rewrite last month's\n * attendance sheet.\n */\n checkInBy: z.string().nullable(),\n checkOutBy: z.string().nullable(),\n});\nexport type AttendanceEntry = z.infer<typeof attendanceEntrySchema>;\n\nexport const attendanceQuerySchema = paginationSchema.extend({\n userId: objectIdSchema.optional(),\n from: isoDateSchema.optional(),\n to: isoDateSchema.optional(),\n});\nexport type AttendanceQuery = z.infer<typeof attendanceQuerySchema>;\n\n/** Owner marking attendance for a member or staff member manually. */\nexport const markAttendanceBodySchema = z.object({\n userId: objectIdSchema,\n date: isoDateSchema.optional(),\n status: attendanceStatusSchema.default('present'),\n checkInAt: isoDateTimeSchema.optional(),\n checkOutAt: isoDateTimeSchema.optional(),\n note: optionalTrimmedString(160),\n});\nexport type MarkAttendanceBody = z.infer<typeof markAttendanceBodySchema>;\n"]}
|
|
1
|
+
{"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — Workout sessions, check-in & attendance\n * ========================================================\n * A member checks in to start a live session, marks exercises done, and checks\n * out. Sessions roll up into attendance records the owner can review, and can\n * also be recorded directly by an owner (manual attendance marking).\n *\n * @module gymmonk-schema/session\n */\n\nimport { z } from 'zod';\nimport { checkInCodeSchema } from './check-in.js';\nimport {\n isoDateSchema,\n isoDateTimeSchema,\n objectIdSchema,\n optionalTrimmedString,\n paginationSchema,\n} from './common.js';\nimport { checkInGeoInputSchema } from './location.js';\nimport { weekdaySchema } from './shared.js';\n\n// ─── Live session widget status ──────────────────────────────────────────────\n\nexport const sessionStatusSchema = z.enum(['idle', 'active', 'completed']);\nexport type SessionStatus = z.infer<typeof sessionStatusSchema>;\n\n// ─── The set stopwatch ───────────────────────────────────────────────────────\n\n/**\n * The member's own stopwatch, which is NOT the session clock.\n *\n * Home shows two running times and they measure different things. The session\n * clock is \"how long have you been in the gym\" — it runs from `checkInAt`,\n * cannot be paused, and is derived rather than stored. This one is \"how long\n * has this set taken\", and the member starts, pauses and resets it freely.\n *\n * Stored as ACCUMULATED + STARTED-AT rather than as a running total, because a\n * total would need the server to tick. Elapsed is\n * `accumulatedSec + (running ? now - startedAt : 0)`, so a paused stopwatch is\n * a plain number, a running one survives a refresh or a dead battery, and no\n * job has to write to the database once a second.\n *\n * `startedAt` is null exactly when `running` is false; the pair is a small\n * state machine, and the backend is the only writer.\n */\nexport const sessionStopwatchSchema = z.object({\n running: z.boolean(),\n /** Seconds banked by previous runs, excluding any run in progress. */\n accumulatedSec: z.number().int().nonnegative(),\n /** When the current run began. Null while paused. */\n startedAt: isoDateTimeSchema.nullable(),\n});\nexport type SessionStopwatch = z.infer<typeof sessionStopwatchSchema>;\n\n/** A stopwatch that has never been started. */\nexport const IDLE_STOPWATCH: SessionStopwatch = {\n running: false,\n accumulatedSec: 0,\n startedAt: null,\n};\n\n/**\n * Resolve a stopwatch to whole seconds elapsed.\n *\n * Shared rather than reimplemented on each side: the backend needs it to bank\n * time on pause, and the client needs it to render every tick. Two copies of\n * this arithmetic would drift the moment one of them forgot the running run.\n */\nexport function stopwatchElapsedSec(stopwatch: SessionStopwatch, now: Date = new Date()): number {\n if (!stopwatch.running || !stopwatch.startedAt) return stopwatch.accumulatedSec;\n const started = Date.parse(stopwatch.startedAt);\n if (Number.isNaN(started)) return stopwatch.accumulatedSec;\n // Floored at the banked value: a client clock behind the server's would\n // otherwise make the number run backwards.\n return stopwatch.accumulatedSec + Math.max(0, Math.floor((now.getTime() - started) / 1000));\n}\n\n// ─── Session entity ──────────────────────────────────────────────────────────\n\nexport const workoutSessionSchema = z.object({\n id: objectIdSchema,\n userId: objectIdSchema,\n centerId: objectIdSchema,\n status: sessionStatusSchema,\n checkInAt: isoDateTimeSchema,\n checkOutAt: isoDateTimeSchema.nullable(),\n /** The plan weekday being trained, if the member is on a plan. */\n planWeekday: weekdaySchema.nullable(),\n completedExerciseIds: z.array(objectIdSchema),\n totalExercises: z.number().int().nonnegative(),\n stopwatch: sessionStopwatchSchema,\n});\nexport type WorkoutSession = z.infer<typeof workoutSessionSchema>;\n\n// ─── Session mutations ───────────────────────────────────────────────────────\n\n/**\n * Check in by scanning the gym's QR poster.\n *\n * `code` is REQUIRED and is the whole point: the center is resolved FROM the\n * scanned code, never from a client-supplied `centerId`. An earlier version of\n * this body took an optional `centerId`, which meant any authenticated member\n * could mark themselves present from their sofa. Presence has to be proven by\n * being close enough to the poster to photograph it.\n *\n * Staff marking someone present by hand is a different, authorised path —\n * `markAttendanceBodySchema` below.\n */\nexport const checkInBodySchema = z\n .object({\n code: checkInCodeSchema,\n })\n // Where the scanning device thought it was. All three optional — see\n // `checkInGeoInputSchema`. The server measures this against the gym's pin and\n // treats a missing fix as unverifiable rather than as a failure.\n .extend(checkInGeoInputSchema.shape);\nexport type CheckInBody = z.infer<typeof checkInBodySchema>;\n\n/**\n * Check out — the same poster, scanned again. Verified against the session's\n * OWN center, so a member cannot close a session by scanning a different gym.\n */\nexport const checkOutBodySchema = z.object({\n code: checkInCodeSchema,\n});\nexport type CheckOutBody = z.infer<typeof checkOutBodySchema>;\n\nexport const markExerciseDoneBodySchema = z.object({\n exerciseId: objectIdSchema,\n done: z.boolean(),\n});\nexport type MarkExerciseDoneBody = z.infer<typeof markExerciseDoneBodySchema>;\n\n/**\n * Drive the set stopwatch.\n *\n * An ACTION, not a state patch. The client says what the member pressed and the\n * server computes the resulting `{ running, accumulatedSec, startedAt }` — which\n * keeps the banking arithmetic in one place and means a client cannot post an\n * arbitrary elapsed time to inflate a workout.\n */\nexport const stopwatchActionBodySchema = z.object({\n action: z.enum(['start', 'pause', 'reset']),\n});\nexport type StopwatchActionBody = z.infer<typeof stopwatchActionBodySchema>;\n\n// ─── Attendance ──────────────────────────────────────────────────────────────\n\nexport const attendanceStatusSchema = z.enum(['present', 'absent', 'rest']);\nexport type AttendanceStatus = z.infer<typeof attendanceStatusSchema>;\n\n/**\n * How an attendance mark came to be recorded.\n *\n * `scan` — the member scanned the gym's QR poster themselves.\n * `manual` — a person put it there: an owner, manager or front desk.\n * `auto` — nobody did. The member never scanned out and the session hit its\n * cap, so the system closed it (see `AUTO_CHECK_OUT_HOURS`).\n *\n * Recorded per SIDE, because the two halves of a visit routinely differ: the\n * common case is a member who scans in and is closed out automatically. An\n * attendance report that cannot tell a scanned departure from an assumed one is\n * reporting a guess as a measurement.\n */\nexport const attendanceSourceSchema = z.enum(['scan', 'manual', 'auto']);\nexport type AttendanceSource = z.infer<typeof attendanceSourceSchema>;\n\n/**\n * How long a session may stay open before it is closed for the member.\n *\n * Shared with the client so the member's screen can say when their session will\n * end, rather than having the number live only in the server that enforces it.\n */\nexport const AUTO_CHECK_OUT_HOURS = 3;\n\nexport const attendanceEntrySchema = z.object({\n date: isoDateSchema,\n checkInAt: isoDateTimeSchema.nullable(),\n checkOutAt: isoDateTimeSchema.nullable(),\n status: attendanceStatusSchema,\n /** How each half was recorded. Null where that half has not happened. */\n checkInSource: attendanceSourceSchema.nullable(),\n checkOutSource: attendanceSourceSchema.nullable(),\n /**\n * WHO marked it, when a person did — a name, snapshotted at the time.\n *\n * Null for `scan` and `auto`, which have no author. A snapshot rather than a\n * live lookup because this is a record of what happened: a manager who later\n * leaves, or changes their name, must not silently rewrite last month's\n * attendance sheet.\n */\n checkInBy: z.string().nullable(),\n checkOutBy: z.string().nullable(),\n\n /**\n * Was the member at the gym when they scanned? THREE answers, never two.\n *\n * `true` we measured, and they were within the gym's radius.\n * `false` we measured, and they were not.\n * `null` we could not measure — nobody pinned the gym, or the phone would\n * not give a fix.\n *\n * Nullable is the whole point. A gym that has never been pinned and a member\n * standing at the front desk must not read the same, and collapsing\n * \"unknown\" into either answer invents a fact: into `true` it launders a\n * guess into a verification, into `false` it accuses somebody over a\n * measurement nobody took.\n *\n * ── This never refuses a check-in ───────────────────────────────────────────\n * It is a REPORT, not a gate, and the ONLY thing location does in this\n * product. Scanning always succeeds. A phone indoors, behind a concrete wall\n * and a floor of equipment, routinely reports a fix 100-150 m out — so\n * refusing on this locked members out of the gym they pay for while catching\n * nobody, since spoofing a coordinate is cheaper than the walk it was meant\n * to enforce. The owner sees the flag and decides.\n */\n atGym: z.boolean().nullable(),\n});\nexport type AttendanceEntry = z.infer<typeof attendanceEntrySchema>;\n\nexport const attendanceQuerySchema = paginationSchema.extend({\n userId: objectIdSchema.optional(),\n from: isoDateSchema.optional(),\n to: isoDateSchema.optional(),\n});\nexport type AttendanceQuery = z.infer<typeof attendanceQuerySchema>;\n\n/** Owner marking attendance for a member or staff member manually. */\nexport const markAttendanceBodySchema = z.object({\n userId: objectIdSchema,\n date: isoDateSchema.optional(),\n status: attendanceStatusSchema.default('present'),\n checkInAt: isoDateTimeSchema.optional(),\n checkOutAt: isoDateTimeSchema.optional(),\n note: optionalTrimmedString(160),\n});\nexport type MarkAttendanceBody = z.infer<typeof markAttendanceBodySchema>;\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gymmonk-schema",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.0",
|
|
4
4
|
"description": "Shared Zod schemas, enums and domain types for GymMonk (fitness SaaS) — single source of truth (SSOT) consumed by gymmonk-backend and gymmonk-web-client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|