aimharder-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CONTEXT.md ADDED
@@ -0,0 +1,33 @@
1
+ # AimHarder domain
2
+
3
+ AimHarder organizes scheduled classes, workout content, personal bookings, and personal activity at a gym. These concepts remain distinct even when they refer to the same training day.
4
+
5
+ ## Language
6
+
7
+ **Class type**:
8
+ A category of class, such as WOD, Metcon, or Open Box.
9
+ _Avoid_: Workout when referring to a class category.
10
+
11
+ **Class session**:
12
+ A specific scheduled class at a gym on a particular date and time.
13
+ _Avoid_: Workout or booking when referring to the scheduled class itself.
14
+
15
+ **Workout**:
16
+ The prescribed training content, which may be shared by multiple class sessions. A workout published in a gym feed does not necessarily belong to one unique session.
17
+ _Avoid_: Class session when referring to training instructions.
18
+
19
+ **Booking**:
20
+ The account holder's reservation for a class session. A booking does not establish attendance.
21
+ _Avoid_: Attendance or completed workout as synonyms for a booking.
22
+
23
+ **Activity entry**:
24
+ A distinct record of the account holder's personal activity. Several entries on the same date remain separate entries, even when their content is similar. An entry does not by itself establish a booking or verified attendance.
25
+ _Avoid_: Booking or attendance as synonyms for an activity record.
26
+
27
+ **Training session**:
28
+ A distinct occurrence of personal training. Several activity entries may describe one training session; grouping requires evidence beyond a shared date or similar content.
29
+ _Avoid_: Treating every activity entry, active day, or booking as one verified training session.
30
+
31
+ **Day with activity**:
32
+ A gym-local calendar date with at least one personal activity entry. It does not state how many distinct training sessions occurred that day.
33
+ _Avoid_: Session count or attendance count when describing days with activity.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rude
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # aimharder-mcp
2
+
3
+ Ask about your AimHarder classes, workouts, bookings and activity from a desktop AI client. This is a local, read-only [MCP server](https://modelcontextprotocol.io/) and an independent project, neither affiliated with nor endorsed by AimHarder.
4
+
5
+ **Release status:** `aimharder-mcp@0.1.0` is not on npm yet. The `npx` example below will work after publication. A [local package archive has been tested](https://github.com/rudeayelo/aimharder-mcp/blob/main/docs/validation.md).
6
+
7
+ **Data limits:** Live checks used one account at one location (9NBC). Other gyms may differ. Workout and booking views can be incomplete, so an empty result may not mean there is nothing to show. [Details](docs/tools.md#coverage-and-interpretation)
8
+
9
+ ## Get started
10
+
11
+ 1. Install Node.js **24 or newer**.
12
+ 2. Supply `AIMHARDER_USERNAME` and `AIMHARDER_PASSWORD` to the server process through your client or a secrets manager. Keep them out of shared configuration. The server assumes `Europe/Madrid` unless you [configure your gym's time zone](docs/configuration.md).
13
+ 3. Add a local stdio server to your client. This generic JSON example assumes the client passes the required environment variables:
14
+
15
+ ```json
16
+ {
17
+ "mcpServers": {
18
+ "aimharder": {
19
+ "command": "npx",
20
+ "args": ["--yes", "aimharder-mcp@0.1.0"]
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ 4. Ask your client an AimHarder question, such as “What is tomorrow's WOD?” The first valid tool call signs in. See the guide for your client below for its configuration format and credential options.
27
+
28
+ ## Desktop clients
29
+
30
+ - [ChatGPT desktop](docs/clients/chatgpt-desktop.md)
31
+ - [Claude Desktop](docs/clients/claude-desktop.md)
32
+ - [Hermes](docs/clients/hermes.md)
33
+ - [OpenClaw](docs/clients/openclaw.md)
34
+
35
+ Hermes has been tested with a local package archive. The ChatGPT desktop STDIO form has been observed; package connections in ChatGPT, Claude Desktop and OpenClaw have not been verified. Mobile apps are outside the current setup guides.
36
+
37
+ ## Tools
38
+
39
+ | Tool | Example question |
40
+ | --- | --- |
41
+ | `get_account_context` | Which gyms can I query? |
42
+ | `get_class_sessions` | What classes are available this week? |
43
+ | `get_published_workouts` | What is tomorrow's WOD? |
44
+ | `get_upcoming_bookings` | When am I booked? |
45
+ | `get_booking_history` | Which past bookings are available? |
46
+ | `get_personal_activity` | What activity did I record this month? |
47
+
48
+ Clients can combine tools for questions about workouts and bookings, or count recent **activity entries**. An activity entry does not establish attendance or one distinct training session. See [tool inputs and coverage](docs/tools.md).
49
+
50
+ ## Roadmap
51
+
52
+ Planned capabilities, without committed dates or versions:
53
+
54
+ - [ ] Create and cancel bookings.
55
+ - [ ] Look up personal exercise RMs and show calculated loads alongside the original `%RM` prescription in future activities.
56
+ - [ ] Record activity results.
57
+ - [ ] Expand compatibility to more AimHarder gyms.
58
+ - [ ] Explore compatibility with mobile apps.
59
+
60
+ ## Contributing
61
+
62
+ Report bugs or propose changes in [GitHub Issues](https://github.com/rudeayelo/aimharder-mcp/issues). Keep credentials and private activity out of reports. See the [development guide](https://github.com/rudeayelo/aimharder-mcp/blob/main/docs/development.md) to contribute code.
63
+
64
+ ## License
65
+
66
+ [MIT](LICENSE).
@@ -0,0 +1,19 @@
1
+ import { z } from 'zod';
2
+ import { activityCoverageSchema, activityEntrySchema } from './activity.js';
3
+ import { calendarDates, dateSchema } from './classes.js';
4
+ import { gymIdSchema } from './config.js';
5
+ export const gymSchema = z.object({ id: gymIdSchema, name: z.string(), timeZone: z.string().nullable(), timeZoneStatus: z.enum(['assumed', 'user-confirmed']) });
6
+ export const activityResponseSchema = z.object({ gym: gymSchema, startDate: dateSchema, endDate: dateSchema, entries: z.array(activityEntrySchema), coverage: activityCoverageSchema, notices: z.array(z.string()) });
7
+ /** Reject responses that cannot establish the requested gym/date coverage. */
8
+ export function validateActivityResponse(value, gym, startDate, endDate) {
9
+ const data = activityResponseSchema.parse(value);
10
+ const dates = [...calendarDates(startDate, endDate)];
11
+ if (data.gym.id !== gym.id || data.gym.timeZone !== gym.timeZone || data.gym.timeZoneStatus !== gym.timeZoneStatus
12
+ || data.startDate !== startDate || data.endDate !== endDate
13
+ || data.entries.some(entry => !dates.includes(entry.date) || entry.timeZone !== gym.timeZone)
14
+ || data.coverage.completedDates.some(date => !dates.includes(date))
15
+ || new Set(data.coverage.completedDates).size !== data.coverage.completedDates.length
16
+ || (data.coverage.status === 'complete' && data.coverage.completedDates.length !== dates.length))
17
+ throw new Error('Activity response scope or coverage could not be confirmed.');
18
+ return data;
19
+ }
@@ -0,0 +1,97 @@
1
+ import { z } from 'zod';
2
+ import { gymSchema, validateActivityResponse } from './activity-consumer-contracts.js';
3
+ import { dateSchema } from './classes.js';
4
+ import { gymIdSchema } from './config.js';
5
+ const options = { gymId: gymIdSchema.optional(), maxWindows: z.number().int().min(1).max(12).default(12) };
6
+ const inputSchema = z.union([
7
+ z.object({ period: z.literal('previous-month'), ...options }).strict(),
8
+ z.object({ startDate: dateSchema, endDate: dateSchema, ...options }).strict()
9
+ .refine(query => query.startDate <= query.endDate, { message: 'startDate must be on or before endDate.' }),
10
+ ]);
11
+ function shiftDate(date, days) {
12
+ const value = new Date(`${date}T12:00:00Z`);
13
+ value.setUTCDate(value.getUTCDate() + days);
14
+ return dateSchema.parse(value.toISOString().slice(0, 10));
15
+ }
16
+ export async function queryActivityPeriod(client, input, now = new Date()) {
17
+ const query = inputSchema.parse(input);
18
+ let gym;
19
+ try {
20
+ const result = await client.callTool({ name: 'get_account_context', arguments: query.gymId ? { gymId: query.gymId } : {} });
21
+ if (result.isError)
22
+ throw new Error();
23
+ gym = z.object({ selectedGym: gymSchema }).parse(result.structuredContent).selectedGym;
24
+ if ((query.gymId && gym.id !== query.gymId) || !gym.timeZone)
25
+ throw new Error();
26
+ }
27
+ catch {
28
+ throw new Error('The selected gym and its time zone could not be established.');
29
+ }
30
+ let startDate;
31
+ let endDate;
32
+ if ('period' in query) {
33
+ const parts = new Intl.DateTimeFormat('en-US', { timeZone: gym.timeZone, year: 'numeric', month: '2-digit' }).formatToParts(now);
34
+ const year = parts.find(part => part.type === 'year').value.padStart(4, '0');
35
+ const month = parts.find(part => part.type === 'month').value;
36
+ endDate = shiftDate(dateSchema.parse(`${year}-${month}-01`), -1);
37
+ startDate = `${endDate.slice(0, 7)}-01`;
38
+ }
39
+ else {
40
+ startDate = query.startDate;
41
+ endDate = query.endDate;
42
+ }
43
+ const entries = new Map();
44
+ const completedDates = new Set();
45
+ const windows = [];
46
+ let coverage = 'incomplete';
47
+ let cursor = startDate;
48
+ for (let index = 0; index < query.maxWindows; index++) {
49
+ // Bound the addition before shifting, including at the maximum supported year.
50
+ const remainingDays = (Date.parse(`${endDate}T12:00:00Z`) - Date.parse(`${cursor}T12:00:00Z`)) / 86_400_000;
51
+ const windowEnd = shiftDate(cursor, Math.min(30, remainingDays));
52
+ try {
53
+ const result = await client.callTool({ name: 'get_personal_activity', arguments: { startDate: cursor, endDate: windowEnd, gymId: gym.id } });
54
+ if (result.isError)
55
+ throw new Error();
56
+ const data = validateActivityResponse(result.structuredContent, gym, cursor, windowEnd);
57
+ const recovered = new Map(entries);
58
+ for (const entry of data.entries) {
59
+ const prior = recovered.get(entry.sourceActivityId);
60
+ if (prior && JSON.stringify(prior) !== JSON.stringify(entry))
61
+ throw new Error();
62
+ recovered.set(entry.sourceActivityId, entry);
63
+ }
64
+ for (const [id, entry] of recovered)
65
+ entries.set(id, entry);
66
+ for (const date of data.coverage.completedDates)
67
+ completedDates.add(date);
68
+ windows.push({ startDate: cursor, endDate: windowEnd, status: data.coverage.status, completedDates: data.coverage.completedDates, notices: [...data.notices, ...(data.coverage.reason ? [data.coverage.reason] : [])] });
69
+ if (data.coverage.status !== 'complete')
70
+ break;
71
+ if (windowEnd === endDate) {
72
+ coverage = 'complete';
73
+ break;
74
+ }
75
+ }
76
+ catch {
77
+ windows.push({ startDate: cursor, endDate: windowEnd, status: 'error', completedDates: [], notices: ['Activity retrieval could not be confirmed; exact period counts are unavailable.'] });
78
+ break;
79
+ }
80
+ cursor = shiftDate(windowEnd, 1);
81
+ }
82
+ const observedEntries = [...entries.values()].sort((a, b) => b.date.localeCompare(a.date) || a.sourceActivityId - b.sourceActivityId);
83
+ const observedDays = new Set(observedEntries.map(entry => entry.date)).size;
84
+ return {
85
+ gym, startDate, endDate, coverage, completedDates: [...completedDates].sort(), windows, maxWindows: query.maxWindows,
86
+ basis: 'activity-entries',
87
+ counts: {
88
+ activityEntries: { observed: entries.size, exact: coverage === 'complete' ? entries.size : null },
89
+ daysWithActivity: { observed: observedDays, exact: coverage === 'complete' ? observedDays : null },
90
+ interpretation: coverage === 'complete' ? 'exact-for-available-calendar-records' : 'recovered-lower-bound',
91
+ },
92
+ entries: observedEntries,
93
+ notices: ['Frequency counts distinct activity entries. Days with activity are supplementary; neither measure establishes class attendance.',
94
+ 'Coverage describes available calendar records and is not an atomic snapshot or proof of attendance.',
95
+ ...(coverage === 'incomplete' ? ['The period is incomplete because retrieval failed, returned incomplete coverage, or reached the configured window limit. Observed counts are recovered lower bounds; exact totals are unavailable.'] : [])],
96
+ };
97
+ }
@@ -0,0 +1,96 @@
1
+ import { z } from 'zod';
2
+ import { dateSchema } from './classes.js';
3
+ import { gymIdSchema } from './config.js';
4
+ import { AimHarderError } from './errors.js';
5
+ import { parseWorkout, workoutSchema } from './workouts.js';
6
+ export const activityQuerySchema = z.object({ startDate: dateSchema, endDate: dateSchema, gymId: gymIdSchema.optional() }).strict()
7
+ .refine(q => q.startDate <= q.endDate && (Date.parse(`${q.endDate}T00:00:00Z`) - Date.parse(`${q.startDate}T00:00:00Z`)) / 86_400_000 < 31, { message: 'Supply an inclusive interval of 1 to 31 consecutive calendar dates.' });
8
+ // Preserve source result names. The user confirmed that time is measured in seconds.
9
+ const resultNumber = z.number().finite().nullable().optional();
10
+ const activityResultSchema = z.object({
11
+ res: resultNumber, reps: resultNumber, time: resultNumber.describe('Recorded time in seconds; unit confirmed by the user.'), rondas: resultNumber,
12
+ desc: z.string().max(100_000).nullable().optional().describe('Original result description for this activity and block; format is not universal across gyms.'),
13
+ rx: z.boolean().nullable().optional(), rxstr: z.string().max(100_000).nullable().optional(),
14
+ });
15
+ const resultDetailSchema = z.object({ TIPOWODs: z.array(z.unknown()), chartData: z.unknown().optional() });
16
+ const chartRowSchema = z.object({ idAction: z.number().int().positive().safe(), desc: z.string().max(100_000).nullable().optional() });
17
+ function resultDescription(chartData, source, activityId) {
18
+ if (chartData == null)
19
+ return {};
20
+ const chart = z.record(z.string(), z.unknown()).safeParse(chartData);
21
+ const block = z.object({ id: z.number().int().positive().safe().optional() }).safeParse(source);
22
+ if (!chart.success || !block.success)
23
+ throw new AimHarderError('INVALID_ACTIVITY_RESPONSE');
24
+ if (block.data.id === undefined)
25
+ return {};
26
+ const rows = chart.data[String(block.data.id)];
27
+ if (rows === undefined)
28
+ return {};
29
+ if (!Array.isArray(rows))
30
+ throw new AimHarderError('INVALID_ACTIVITY_RESPONSE');
31
+ const descriptions = [];
32
+ for (const row of rows) {
33
+ if (typeof row !== 'object' || row === null || row.idAction !== activityId)
34
+ continue;
35
+ const parsed = chartRowSchema.safeParse(row);
36
+ if (!parsed.success)
37
+ throw new AimHarderError('INVALID_ACTIVITY_RESPONSE');
38
+ descriptions.push(parsed.data.desc);
39
+ }
40
+ if (new Set(descriptions).size > 1)
41
+ throw new AimHarderError('INVALID_ACTIVITY_RESPONSE');
42
+ return descriptions.length && descriptions[0] !== undefined ? { desc: descriptions[0] } : {};
43
+ }
44
+ export const activityEntrySchema = workoutSchema.pick({ titles: true, blocks: true, exercises: true }).extend({
45
+ blocks: z.array(workoutSchema.shape.blocks.element.extend({ result: activityResultSchema.optional() })),
46
+ sourceActivityId: z.number().int().positive().safe(), date: dateSchema, timeZone: z.string(), startTime: z.null(), trainingSessionId: z.null(),
47
+ });
48
+ export const activityCoverageSchema = z.object({ status: z.enum(['complete', 'incomplete']), scope: z.literal('account-activity-calendar'), completedDates: z.array(dateSchema), reason: z.string().nullable() });
49
+ const ratesSchema = z.object({ ids: z.array(z.number().int().positive().safe()).max(500), colors: z.array(z.string().nullable()).optional() })
50
+ .catchall(z.array(z.unknown()))
51
+ .refine(row => Object.keys(row).every(key => key === 'ids' || key === 'colors' || /^[1-9]\d*$/.test(key)));
52
+ const calendarSchema = z.object({ workouts: z.union([z.array(z.never()), z.record(dateSchema, z.object({ rates: ratesSchema, TIPOWODs: z.record(z.string().regex(/^[1-9]\d*$/), z.unknown()) }).strict())]) }).strict();
53
+ export function parseActivityCalendar(body, month) {
54
+ const parsed = calendarSchema.safeParse(body);
55
+ if (!parsed.success)
56
+ throw new AimHarderError('INVALID_ACTIVITY_RESPONSE');
57
+ const entries = new Map();
58
+ const seen = new Map();
59
+ for (const [date, day] of Object.entries(parsed.data.workouts)) {
60
+ if (!date.startsWith(`${month}-`))
61
+ throw new AimHarderError('INVALID_ACTIVITY_RESPONSE');
62
+ for (const id of day.rates.ids) {
63
+ if (seen.has(id) && seen.get(id) !== date)
64
+ throw new AimHarderError('INVALID_ACTIVITY_RESPONSE');
65
+ seen.set(id, date);
66
+ }
67
+ entries.set(date, [...new Set(day.rates.ids)]);
68
+ }
69
+ return entries;
70
+ }
71
+ const identitySchema = z.object({ userId: z.number().int().positive().safe(), boxId: z.number().int().positive().safe() });
72
+ export function parseActivityDetail(body, sourceActivityId, date, accountId, boxId, gymId, timeZone) {
73
+ const identity = identitySchema.safeParse(body);
74
+ if (!identity.success || identity.data.userId !== accountId)
75
+ throw new AimHarderError('INVALID_ACTIVITY_RESPONSE');
76
+ // The calendar is account-wide. Verify the detail's gym before projecting it.
77
+ if (identity.data.boxId !== boxId)
78
+ return null;
79
+ const workout = parseWorkout(body, { id: sourceActivityId, wodClass: 'personal-activity' }, gymId, timeZone);
80
+ if (!workout || workout.date !== date)
81
+ throw new AimHarderError('INVALID_ACTIVITY_RESPONSE');
82
+ const details = resultDetailSchema.safeParse(body);
83
+ if (!details.success)
84
+ throw new AimHarderError('INVALID_ACTIVITY_RESPONSE');
85
+ const blocks = workout.blocks.map((block, index) => {
86
+ const source = details.data.TIPOWODs[index];
87
+ // Deleted blocks retain their position, but never expose recorded results.
88
+ if (typeof source === 'object' && source !== null && 'deleted' in source && source.deleted === true)
89
+ return { ...block, result: {} };
90
+ const result = activityResultSchema.omit({ desc: true }).safeParse(source);
91
+ if (!result.success)
92
+ throw new AimHarderError('INVALID_ACTIVITY_RESPONSE');
93
+ return { ...block, result: { ...result.data, ...resultDescription(details.data.chartData, source, sourceActivityId) } };
94
+ });
95
+ return activityEntrySchema.parse({ sourceActivityId, date, timeZone, startTime: null, trainingSessionId: null, titles: workout.titles, blocks, exercises: workout.exercises });
96
+ }
@@ -0,0 +1,105 @@
1
+ import { z } from 'zod';
2
+ import { dateSchema, timeSchema } from './classes.js';
3
+ import { AimHarderError } from './errors.js';
4
+ const sourceId = z.number().int().positive().safe();
5
+ export const upcomingBookingSchema = z.object({
6
+ sourceBookingId: sourceId, sessionId: z.null(),
7
+ date: dateSchema, dateLabel: z.string(), startTime: timeSchema, timeLabel: z.string(), timeZone: z.string(),
8
+ classType: z.object({ id: z.null(), name: z.string().nullable() }),
9
+ state: z.enum(['booked', 'waitlisted', 'unknown']), sourceState: z.number().int().safe().nullable(),
10
+ });
11
+ const responseSchema = z.object({
12
+ nextClasses: z.array(z.object({
13
+ id: sourceId, day: z.string(),
14
+ time: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d - (?:[01]\d|2[0-3]):[0-5]\d$/),
15
+ className: z.string().min(1).max(300).refine((name) => name.trim().length > 0).nullish(),
16
+ bookState: z.number().int().safe().nullish(),
17
+ })),
18
+ // History is returned by this operation but is not interpreted or exposed by this slice.
19
+ history: z.array(z.unknown()),
20
+ }).strict();
21
+ const months = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
22
+ const weekdays = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado'];
23
+ /** Accept only the verified Spanish full-date format; never let Date.parse guess a locale. */
24
+ function calendarDate(label) {
25
+ const match = /^(Domingo|Lunes|Martes|Miércoles|Jueves|Viernes|Sábado), (\d{1,2}) de ([A-Za-z]+) de (\d{4})$/.exec(label);
26
+ if (!match)
27
+ throw new AimHarderError('INVALID_BOOKING_RESPONSE');
28
+ const month = months.indexOf(match[3]);
29
+ const date = `${match[4]}-${String(month + 1).padStart(2, '0')}-${match[2].padStart(2, '0')}`;
30
+ if (month < 0 || !dateSchema.safeParse(date).success || weekdays[new Date(`${date}T00:00:00Z`).getUTCDay()] !== match[1]) {
31
+ throw new AimHarderError('INVALID_BOOKING_RESPONSE');
32
+ }
33
+ return date;
34
+ }
35
+ export function parseUpcomingBookings(body, timeZone) {
36
+ const parsed = responseSchema.safeParse(body);
37
+ if (!parsed.success)
38
+ throw new AimHarderError('INVALID_BOOKING_RESPONSE');
39
+ const ids = new Set();
40
+ return parsed.data.nextClasses.map((row) => {
41
+ if (ids.has(row.id))
42
+ throw new AimHarderError('INVALID_BOOKING_RESPONSE');
43
+ ids.add(row.id);
44
+ return {
45
+ sourceBookingId: row.id, sessionId: null,
46
+ date: calendarDate(row.day), dateLabel: row.day,
47
+ startTime: row.time.slice(0, 5), timeLabel: row.time, timeZone,
48
+ classType: { id: null, name: row.className ?? null },
49
+ state: row.bookState === 1 ? 'booked' : row.bookState === 0 ? 'waitlisted' : 'unknown',
50
+ sourceState: row.bookState ?? null,
51
+ };
52
+ });
53
+ }
54
+ export const historicalBookingSchema = upcomingBookingSchema.extend({
55
+ state: z.enum(['booked', 'waitlisted', 'late-cancelled', 'unknown']),
56
+ attendance: z.literal('unverified'),
57
+ sourceFlags: z.object({ assist: z.number().int().safe().nullable(), lateCancel: z.number().int().safe().nullable() }),
58
+ });
59
+ const historyResponseSchema = z.object({ nextClasses: z.array(z.unknown()), history: z.array(z.unknown()) }).strict();
60
+ const historyRowSchema = responseSchema.shape.nextClasses.element.extend({
61
+ assist: z.number().int().safe().nullish(), lateCancel: z.number().int().safe().nullish(),
62
+ });
63
+ export function parseBookingHistory(body, timeZone) {
64
+ const parsed = historyResponseSchema.safeParse(body);
65
+ if (!parsed.success)
66
+ throw new AimHarderError('INVALID_BOOKING_RESPONSE');
67
+ const rows = new Map();
68
+ const conflicts = new Set();
69
+ let partial = false;
70
+ for (const raw of parsed.data.history) {
71
+ const entry = historyRowSchema.safeParse(raw);
72
+ if (!entry.success) {
73
+ partial = true;
74
+ const identity = z.object({ id: sourceId }).safeParse(raw);
75
+ if (identity.success)
76
+ conflicts.add(identity.data.id);
77
+ continue;
78
+ }
79
+ const row = entry.data;
80
+ try {
81
+ const base = parseUpcomingBookings({ nextClasses: [row], history: [] }, timeZone)[0];
82
+ const booking = {
83
+ ...base, state: row.lateCancel === 1 ? 'late-cancelled'
84
+ : row.lateCancel != null && row.lateCancel !== 0 ? 'unknown' : base.state,
85
+ attendance: 'unverified', sourceFlags: { assist: row.assist ?? null, lateCancel: row.lateCancel ?? null },
86
+ };
87
+ const previous = rows.get(row.id);
88
+ if (previous && JSON.stringify(previous) !== JSON.stringify(booking)) {
89
+ conflicts.add(row.id);
90
+ partial = true;
91
+ }
92
+ rows.set(row.id, booking);
93
+ }
94
+ catch {
95
+ partial = true;
96
+ conflicts.add(row.id);
97
+ }
98
+ }
99
+ for (const id of conflicts)
100
+ rows.delete(id);
101
+ // A response in which nothing can be interpreted is an error, never an empty history.
102
+ if (partial && rows.size === 0)
103
+ throw new AimHarderError('INVALID_BOOKING_RESPONSE');
104
+ return { bookings: [...rows.values()].sort((a, b) => `${b.date} ${b.startTime}`.localeCompare(`${a.date} ${a.startTime}`) || a.sourceBookingId - b.sourceBookingId), partial };
105
+ }
@@ -0,0 +1,59 @@
1
+ import { z } from 'zod';
2
+ import { gymIdSchema } from './config.js';
3
+ import { AimHarderError } from './errors.js';
4
+ // Calendar arithmetic uses UTC only as a Gregorian date counter, never as the gym zone.
5
+ export const dateSchema = z.iso.date().refine((date) => !date.startsWith('0000'));
6
+ export const timeSchema = z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/);
7
+ export const classQuerySchema = z.object({
8
+ startDate: dateSchema, endDate: dateSchema, gymId: gymIdSchema.optional(),
9
+ startTime: timeSchema.optional(), className: z.string().min(1).max(300).optional(),
10
+ }).strict().refine((query) => query.startDate <= query.endDate, { message: 'startDate must be on or before endDate.' });
11
+ const sourceId = z.number().int().positive().safe();
12
+ const count = z.number().int().nonnegative().safe();
13
+ export const classSessionSchema = z.object({
14
+ sessionId: z.string(), sourceId,
15
+ date: dateSchema, startTime: timeSchema, timeLabel: z.string(), timeZone: z.string(),
16
+ classType: z.object({ id: sourceId, name: z.string() }),
17
+ occupancy: count.nullable(), capacity: count.nullable(),
18
+ });
19
+ const timeLabelSchema = z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d - (?:[01]\d|2[0-3]):[0-5]\d$/);
20
+ const dailySchema = z.object({
21
+ bookings: z.array(z.object({
22
+ id: sourceId, classId: sourceId,
23
+ className: z.string().min(1).max(300).refine((name) => name.trim().length > 0),
24
+ time: timeLabelSchema, ocupation: count.nullish(), limit: count.nullish(),
25
+ })),
26
+ // Only the observed complete-day envelope is accepted. New pagination/error fields
27
+ // need investigation instead of being stripped and misreported as complete coverage.
28
+ clasesDisp: z.string().optional(), day: z.string().optional(),
29
+ timetable: z.array(z.unknown()).optional(), seminars: z.array(z.string()).optional(),
30
+ resmsgs: z.array(z.never()).optional(),
31
+ }).strict();
32
+ export function* calendarDates(startDate, endDate) {
33
+ let date = startDate;
34
+ while (true) {
35
+ yield date;
36
+ if (date === endDate)
37
+ return;
38
+ const next = new Date(`${date}T00:00:00.000Z`);
39
+ next.setUTCDate(next.getUTCDate() + 1);
40
+ date = next.toISOString().slice(0, 10);
41
+ }
42
+ }
43
+ export function parseClassDay(body, gymId, date, timeZone) {
44
+ const parsed = dailySchema.safeParse(body);
45
+ if (!parsed.success)
46
+ throw new AimHarderError('INVALID_CLASS_RESPONSE');
47
+ const ids = new Set();
48
+ return parsed.data.bookings.map((row) => {
49
+ if (ids.has(row.id))
50
+ throw new AimHarderError('INVALID_CLASS_RESPONSE');
51
+ ids.add(row.id);
52
+ return {
53
+ sessionId: `${gymId}:${date}:${row.id}`, sourceId: row.id,
54
+ date, startTime: row.time.slice(0, 5), timeLabel: row.time, timeZone,
55
+ classType: { id: row.classId, name: row.className },
56
+ occupancy: row.ocupation ?? null, capacity: row.limit ?? null,
57
+ };
58
+ });
59
+ }