@nickchristensen/cliftin 1.0.2
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/README.md +206 -0
- package/bin/dev.cmd +3 -0
- package/bin/dev.js +5 -0
- package/bin/run.cmd +3 -0
- package/bin/run.js +5 -0
- package/dist/commands/exercises/list.d.ts +13 -0
- package/dist/commands/exercises/list.js +54 -0
- package/dist/commands/exercises/show.d.ts +21 -0
- package/dist/commands/exercises/show.js +140 -0
- package/dist/commands/programs/list.d.ts +6 -0
- package/dist/commands/programs/list.js +27 -0
- package/dist/commands/programs/show.d.ts +13 -0
- package/dist/commands/programs/show.js +71 -0
- package/dist/commands/workouts/list.d.ts +15 -0
- package/dist/commands/workouts/list.js +72 -0
- package/dist/commands/workouts/show.d.ts +9 -0
- package/dist/commands/workouts/show.js +72 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/config.js +24 -0
- package/dist/lib/db.d.ts +104 -0
- package/dist/lib/db.js +15 -0
- package/dist/lib/json-error.d.ts +5 -0
- package/dist/lib/json-error.js +6 -0
- package/dist/lib/json-weight.d.ts +7 -0
- package/dist/lib/json-weight.js +42 -0
- package/dist/lib/names.d.ts +5 -0
- package/dist/lib/names.js +70 -0
- package/dist/lib/output.d.ts +7 -0
- package/dist/lib/output.js +73 -0
- package/dist/lib/repositories/exercises.d.ts +30 -0
- package/dist/lib/repositories/exercises.js +280 -0
- package/dist/lib/repositories/programs.d.ts +6 -0
- package/dist/lib/repositories/programs.js +217 -0
- package/dist/lib/repositories/selectors.d.ts +3 -0
- package/dist/lib/repositories/selectors.js +35 -0
- package/dist/lib/repositories/workouts.d.ts +13 -0
- package/dist/lib/repositories/workouts.js +128 -0
- package/dist/lib/rpe.d.ts +2 -0
- package/dist/lib/rpe.js +4 -0
- package/dist/lib/time.d.ts +10 -0
- package/dist/lib/time.js +49 -0
- package/dist/lib/types.d.ts +123 -0
- package/dist/lib/types.js +1 -0
- package/dist/lib/units.d.ts +16 -0
- package/dist/lib/units.js +81 -0
- package/oclif.manifest.json +386 -0
- package/package.json +93 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
import { formatEquipmentDisplayName, formatExerciseDisplayName, formatMuscleLabel } from '../names.js';
|
|
3
|
+
import { appleSecondsToIso, dateRangeToAppleSeconds } from '../time.js';
|
|
4
|
+
import { convertDisplayWeightToKg, convertKgToDisplayVolume, convertKgToDisplayWeight, resolveExerciseWeightUnit, } from '../units.js';
|
|
5
|
+
import { resolveIdOrName } from './selectors.js';
|
|
6
|
+
import { getWorkoutDetail } from './workouts.js';
|
|
7
|
+
function asBool(value) {
|
|
8
|
+
return value === 1;
|
|
9
|
+
}
|
|
10
|
+
function renderSelectorCandidateList(candidates) {
|
|
11
|
+
return candidates
|
|
12
|
+
.map((candidate) => ({
|
|
13
|
+
id: candidate.id,
|
|
14
|
+
name: formatExerciseDisplayName(candidate.name, asBool(candidate.isUserCreated)),
|
|
15
|
+
}))
|
|
16
|
+
.map((candidate) => `${candidate.id}:${candidate.name}`)
|
|
17
|
+
.join(', ');
|
|
18
|
+
}
|
|
19
|
+
export async function resolveExerciseSelector(db, selector) {
|
|
20
|
+
if (/^\d+$/.test(selector))
|
|
21
|
+
return Number(selector);
|
|
22
|
+
const normalizedSelector = selector.toLowerCase().trim();
|
|
23
|
+
const rows = await db
|
|
24
|
+
.selectFrom('ZEXERCISEINFORMATION')
|
|
25
|
+
.select(['Z_PK as id', 'ZISUSERCREATED as isUserCreated', 'ZNAME as name'])
|
|
26
|
+
.where('ZSOFTDELETED', 'is not', 1)
|
|
27
|
+
.execute();
|
|
28
|
+
const exact = rows.filter((row) => {
|
|
29
|
+
const rawName = (row.name ?? '').toLowerCase();
|
|
30
|
+
const displayName = formatExerciseDisplayName(row.name, asBool(row.isUserCreated)).toLowerCase();
|
|
31
|
+
return rawName === normalizedSelector || displayName === normalizedSelector;
|
|
32
|
+
});
|
|
33
|
+
if (exact.length === 1)
|
|
34
|
+
return exact[0].id;
|
|
35
|
+
if (exact.length > 1) {
|
|
36
|
+
throw new Error(`Selector "${selector}" is ambiguous: ${renderSelectorCandidateList(exact)}`);
|
|
37
|
+
}
|
|
38
|
+
const partial = rows.filter((row) => {
|
|
39
|
+
const rawName = (row.name ?? '').toLowerCase();
|
|
40
|
+
const displayName = formatExerciseDisplayName(row.name, asBool(row.isUserCreated)).toLowerCase();
|
|
41
|
+
return rawName.includes(normalizedSelector) || displayName.includes(normalizedSelector);
|
|
42
|
+
});
|
|
43
|
+
if (partial.length === 1)
|
|
44
|
+
return partial[0].id;
|
|
45
|
+
if (partial.length > 1) {
|
|
46
|
+
throw new Error(`Selector "${selector}" is ambiguous: ${renderSelectorCandidateList(partial)}`);
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`No records found for selector: ${selector}`);
|
|
49
|
+
}
|
|
50
|
+
export async function listExercises(db, filters) {
|
|
51
|
+
const nameLike = filters.name ? `%${filters.name}%` : undefined;
|
|
52
|
+
const normalizedNameLike = filters.name ? `%${filters.name.toLowerCase().replaceAll('_', ' ')}%` : undefined;
|
|
53
|
+
let query = db
|
|
54
|
+
.selectFrom('ZEXERCISEINFORMATION as ei')
|
|
55
|
+
.leftJoin('ZEQUIPMENT2 as eq', 'eq.Z_PK', 'ei.ZEQUIPMENT')
|
|
56
|
+
.leftJoin('ZEXERCISECONFIGURATION as ec', 'ec.ZINFORMATION', 'ei.Z_PK')
|
|
57
|
+
.leftJoin('ZEXERCISERESULT as er', 'er.ZCONFIGURATION', 'ec.Z_PK')
|
|
58
|
+
.leftJoin('ZWORKOUTRESULT as wr', 'wr.Z_PK', 'er.ZWORKOUT')
|
|
59
|
+
.select([
|
|
60
|
+
'ei.Z_PK as id',
|
|
61
|
+
'ei.ZNAME as name',
|
|
62
|
+
'ei.ZISUSERCREATED as isUserCreated',
|
|
63
|
+
'ei.ZMUSCLES as primaryMuscles',
|
|
64
|
+
'ei.ZSECONDARYMUSCLES as secondaryMuscles',
|
|
65
|
+
'ei.ZTIMERBASED as timerBased',
|
|
66
|
+
'ei.ZSUPPORTSONEREPMAX as supports1RM',
|
|
67
|
+
'eq.ZNAME as equipment',
|
|
68
|
+
'eq.ZID as equipmentId',
|
|
69
|
+
(eb) => eb.fn.max('wr.ZSTARTDATE').as('lastPerformed'),
|
|
70
|
+
(eb) => eb.fn.count('wr.Z_PK').distinct().as('timesPerformed'),
|
|
71
|
+
])
|
|
72
|
+
.where('ei.ZSOFTDELETED', 'is not', 1)
|
|
73
|
+
.groupBy(['ei.Z_PK', 'eq.ZNAME', 'eq.ZID'])
|
|
74
|
+
.orderBy('ei.ZNAME', 'asc');
|
|
75
|
+
if (nameLike && normalizedNameLike) {
|
|
76
|
+
query = query.where((eb) => eb.or([
|
|
77
|
+
eb('ei.ZNAME', 'like', nameLike),
|
|
78
|
+
sql `lower(replace(ei.ZNAME, '_', ' ')) like ${normalizedNameLike}`,
|
|
79
|
+
]));
|
|
80
|
+
}
|
|
81
|
+
if (filters.muscle) {
|
|
82
|
+
query = query.where((eb) => eb.or([eb('ei.ZMUSCLES', 'like', `%${filters.muscle}%`), eb('ei.ZSECONDARYMUSCLES', 'like', `%${filters.muscle}%`)]));
|
|
83
|
+
}
|
|
84
|
+
if (filters.equipment) {
|
|
85
|
+
query = query.where((eb) => eb.or([eb('eq.ZNAME', 'like', `%${filters.equipment}%`), eb('eq.ZID', 'like', `%${filters.equipment}%`)]));
|
|
86
|
+
}
|
|
87
|
+
const rows = await query.execute();
|
|
88
|
+
const summaries = rows.map((row) => ({
|
|
89
|
+
equipment: formatEquipmentDisplayName(row.equipment, row.equipmentId),
|
|
90
|
+
id: row.id,
|
|
91
|
+
lastPerformed: appleSecondsToIso(row.lastPerformed),
|
|
92
|
+
name: formatExerciseDisplayName(row.name, asBool(row.isUserCreated)),
|
|
93
|
+
primaryMuscles: formatMuscleLabel(row.primaryMuscles),
|
|
94
|
+
secondaryMuscles: formatMuscleLabel(row.secondaryMuscles),
|
|
95
|
+
supports1RM: asBool(row.supports1RM),
|
|
96
|
+
timerBased: asBool(row.timerBased),
|
|
97
|
+
timesPerformed: Number(row.timesPerformed),
|
|
98
|
+
}));
|
|
99
|
+
const sort = filters.sort ?? 'name';
|
|
100
|
+
if (sort === 'timesPerformed') {
|
|
101
|
+
summaries.sort((a, b) => b.timesPerformed - a.timesPerformed || a.id - b.id);
|
|
102
|
+
return summaries;
|
|
103
|
+
}
|
|
104
|
+
if (sort === 'lastPerformed') {
|
|
105
|
+
summaries.sort((a, b) => {
|
|
106
|
+
if (a.lastPerformed === b.lastPerformed)
|
|
107
|
+
return a.id - b.id;
|
|
108
|
+
if (a.lastPerformed === null)
|
|
109
|
+
return 1;
|
|
110
|
+
if (b.lastPerformed === null)
|
|
111
|
+
return -1;
|
|
112
|
+
return b.lastPerformed.localeCompare(a.lastPerformed);
|
|
113
|
+
});
|
|
114
|
+
return summaries;
|
|
115
|
+
}
|
|
116
|
+
summaries.sort((a, b) => {
|
|
117
|
+
const aName = (a.name ?? '').toLowerCase();
|
|
118
|
+
const bName = (b.name ?? '').toLowerCase();
|
|
119
|
+
if (aName === bName)
|
|
120
|
+
return a.id - b.id;
|
|
121
|
+
return aName.localeCompare(bName);
|
|
122
|
+
});
|
|
123
|
+
return summaries;
|
|
124
|
+
}
|
|
125
|
+
export async function getExerciseHistoryRows(db, exerciseId, filters) {
|
|
126
|
+
const unitPreference = await resolveExerciseWeightUnit(db, exerciseId);
|
|
127
|
+
const dateRange = dateRangeToAppleSeconds({ from: filters.from, to: filters.to });
|
|
128
|
+
const minWeightKg = filters.minWeight === undefined ? undefined : convertDisplayWeightToKg(filters.minWeight, unitPreference);
|
|
129
|
+
const maxWeightKg = filters.maxWeight === undefined ? undefined : convertDisplayWeightToKg(filters.maxWeight, unitPreference);
|
|
130
|
+
let query = db
|
|
131
|
+
.selectFrom('ZWORKOUTRESULT as wr')
|
|
132
|
+
.innerJoin('ZEXERCISERESULT as er', 'er.ZWORKOUT', 'wr.Z_PK')
|
|
133
|
+
.innerJoin('ZEXERCISECONFIGURATION as ec', 'ec.Z_PK', 'er.ZCONFIGURATION')
|
|
134
|
+
.innerJoin('ZEXERCISEINFORMATION as ei', 'ei.Z_PK', 'ec.ZINFORMATION')
|
|
135
|
+
.leftJoin('ZROUTINE as r', 'r.Z_PK', 'wr.ZROUTINE')
|
|
136
|
+
.leftJoin('ZPERIOD as per', 'per.Z_PK', 'r.ZPERIOD')
|
|
137
|
+
.leftJoin('ZWORKOUTPLAN as pDirect', 'pDirect.Z_PK', 'r.ZWORKOUTPLAN')
|
|
138
|
+
.leftJoin('ZWORKOUTPLAN as pFromPeriod', 'pFromPeriod.Z_PK', 'per.ZWORKOUTPLAN')
|
|
139
|
+
.leftJoin('ZGYMSETRESULT as gs', 'gs.ZEXERCISE', 'er.Z_PK')
|
|
140
|
+
.select([
|
|
141
|
+
'wr.Z_PK as workoutId',
|
|
142
|
+
'wr.ZSTARTDATE as startDate',
|
|
143
|
+
'wr.ZROUTINENAME as routineNameFromResult',
|
|
144
|
+
'r.ZNAME as routineNameFromPlan',
|
|
145
|
+
(eb) => eb.fn.count('gs.Z_PK').as('sets'),
|
|
146
|
+
(eb) => eb.fn.coalesce(eb.fn.sum('gs.ZREPS'), eb.val(0)).as('totalReps'),
|
|
147
|
+
(eb) => eb.fn.max('gs.ZREPS').as('topReps'),
|
|
148
|
+
(eb) => eb.fn.max('gs.ZWEIGHT').as('topWeight'),
|
|
149
|
+
(eb) => eb.fn.coalesce(eb.fn.sum('gs.ZVOLUME'), eb.val(0)).as('volume'),
|
|
150
|
+
])
|
|
151
|
+
.where('ei.Z_PK', '=', exerciseId)
|
|
152
|
+
.groupBy(['wr.Z_PK', 'wr.ZSTARTDATE', 'wr.ZROUTINENAME', 'r.ZNAME'])
|
|
153
|
+
.orderBy('wr.ZSTARTDATE', 'desc');
|
|
154
|
+
if (filters.program) {
|
|
155
|
+
const programId = await resolveIdOrName(db, 'ZWORKOUTPLAN', filters.program);
|
|
156
|
+
query = query.where((eb) => eb.or([eb('r.ZWORKOUTPLAN', '=', programId), eb('per.ZWORKOUTPLAN', '=', programId)]));
|
|
157
|
+
}
|
|
158
|
+
if (filters.routine) {
|
|
159
|
+
const routineId = await resolveIdOrName(db, 'ZROUTINE', filters.routine);
|
|
160
|
+
query = query.where('wr.ZROUTINE', '=', routineId);
|
|
161
|
+
}
|
|
162
|
+
if (dateRange.from !== undefined)
|
|
163
|
+
query = query.where('wr.ZSTARTDATE', '>=', dateRange.from);
|
|
164
|
+
if (dateRange.to !== undefined)
|
|
165
|
+
query = query.where('wr.ZSTARTDATE', '<=', dateRange.to);
|
|
166
|
+
if (filters.minReps !== undefined)
|
|
167
|
+
query = query.having((eb) => eb.fn.max('gs.ZREPS'), '>=', filters.minReps);
|
|
168
|
+
if (filters.maxReps !== undefined)
|
|
169
|
+
query = query.having((eb) => eb.fn.max('gs.ZREPS'), '<=', filters.maxReps);
|
|
170
|
+
if (minWeightKg !== undefined)
|
|
171
|
+
query = query.having((eb) => eb.fn.max('gs.ZWEIGHT'), '>=', minWeightKg);
|
|
172
|
+
if (maxWeightKg !== undefined)
|
|
173
|
+
query = query.having((eb) => eb.fn.max('gs.ZWEIGHT'), '<=', maxWeightKg);
|
|
174
|
+
if (filters.limit !== undefined)
|
|
175
|
+
query = query.limit(filters.limit);
|
|
176
|
+
const rows = await query.execute();
|
|
177
|
+
const normalized = rows.map((row) => ({
|
|
178
|
+
date: appleSecondsToIso(row.startDate),
|
|
179
|
+
routine: row.routineNameFromResult ?? row.routineNameFromPlan,
|
|
180
|
+
sets: Number(row.sets),
|
|
181
|
+
topReps: row.topReps,
|
|
182
|
+
topWeight: convertKgToDisplayWeight(row.topWeight, unitPreference),
|
|
183
|
+
totalReps: Number(row.totalReps),
|
|
184
|
+
volume: convertKgToDisplayVolume(Number(row.volume), unitPreference) ?? 0,
|
|
185
|
+
workoutId: row.workoutId,
|
|
186
|
+
}));
|
|
187
|
+
return normalized;
|
|
188
|
+
}
|
|
189
|
+
export async function getExerciseHistoryWithSetsRows(db, exerciseId, filters) {
|
|
190
|
+
const summaryRows = await getExerciseHistoryRows(db, exerciseId, filters);
|
|
191
|
+
if (summaryRows.length === 0)
|
|
192
|
+
return [];
|
|
193
|
+
const workouts = await Promise.all(summaryRows.map((summary) => getWorkoutDetail(db, summary.workoutId)));
|
|
194
|
+
const workoutsById = new Map(workouts.map((workout) => [workout.id, workout]));
|
|
195
|
+
return summaryRows.map((summary) => {
|
|
196
|
+
const workout = workoutsById.get(summary.workoutId);
|
|
197
|
+
const exercise = workout?.exercises.find((entry) => entry.exerciseId === exerciseId);
|
|
198
|
+
return {
|
|
199
|
+
date: summary.date,
|
|
200
|
+
routine: summary.routine,
|
|
201
|
+
sets: (exercise?.sets ?? []).map((set) => ({
|
|
202
|
+
reps: set.reps,
|
|
203
|
+
setId: set.id,
|
|
204
|
+
timeSeconds: set.timeSeconds,
|
|
205
|
+
volume: set.volume,
|
|
206
|
+
weight: set.weight,
|
|
207
|
+
})),
|
|
208
|
+
topReps: summary.topReps,
|
|
209
|
+
topWeight: summary.topWeight,
|
|
210
|
+
totalReps: summary.totalReps,
|
|
211
|
+
volume: summary.volume,
|
|
212
|
+
workoutId: summary.workoutId,
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
export async function getExerciseDetail(db, exerciseId, historyLimit = 3) {
|
|
217
|
+
const row = await db
|
|
218
|
+
.selectFrom('ZEXERCISEINFORMATION as ei')
|
|
219
|
+
.leftJoin('ZEQUIPMENT2 as eq', 'eq.Z_PK', 'ei.ZEQUIPMENT')
|
|
220
|
+
.select([
|
|
221
|
+
'ei.Z_PK as id',
|
|
222
|
+
'ei.ZNAME as name',
|
|
223
|
+
'ei.ZISUSERCREATED as isUserCreated',
|
|
224
|
+
'ei.ZMUSCLES as primaryMuscles',
|
|
225
|
+
'ei.ZSECONDARYMUSCLES as secondaryMuscles',
|
|
226
|
+
'ei.ZDEFAULTPROGRESSMETRIC as defaultProgressMetric',
|
|
227
|
+
'ei.ZPERCEPTIONSCALE as perceptionScale',
|
|
228
|
+
'ei.ZTIMERBASED as timerBased',
|
|
229
|
+
'ei.ZSUPPORTSONEREPMAX as supports1RM',
|
|
230
|
+
'eq.ZNAME as equipment',
|
|
231
|
+
'eq.ZID as equipmentId',
|
|
232
|
+
])
|
|
233
|
+
.where('ei.Z_PK', '=', exerciseId)
|
|
234
|
+
.executeTakeFirst();
|
|
235
|
+
if (!row)
|
|
236
|
+
throw new Error(`Exercise not found: ${exerciseId}`);
|
|
237
|
+
const routineRows = await db
|
|
238
|
+
.selectFrom('Z_12ROUTINES as j')
|
|
239
|
+
.innerJoin('ZEXERCISECONFIGURATION as ec', 'ec.Z_PK', 'j.Z_12EXERCISES')
|
|
240
|
+
.innerJoin('ZROUTINE as r', 'r.Z_PK', 'j.Z_28ROUTINES')
|
|
241
|
+
.select(['r.ZNAME as routineName'])
|
|
242
|
+
.where('ec.ZINFORMATION', '=', exerciseId)
|
|
243
|
+
.where('r.ZSOFTDELETED', 'is not', 1)
|
|
244
|
+
.groupBy('r.ZNAME')
|
|
245
|
+
.orderBy('r.ZNAME', 'asc')
|
|
246
|
+
.execute();
|
|
247
|
+
const workoutCountRow = await db
|
|
248
|
+
.selectFrom('ZEXERCISERESULT as er')
|
|
249
|
+
.innerJoin('ZEXERCISECONFIGURATION as ec', 'ec.Z_PK', 'er.ZCONFIGURATION')
|
|
250
|
+
.select((eb) => eb.fn.count('er.ZWORKOUT').distinct().as('totalWorkouts'))
|
|
251
|
+
.where('ec.ZINFORMATION', '=', exerciseId)
|
|
252
|
+
.executeTakeFirst();
|
|
253
|
+
const latestHistory = await getExerciseHistoryRows(db, exerciseId, { limit: 1 });
|
|
254
|
+
return {
|
|
255
|
+
defaultProgressMetric: row.defaultProgressMetric,
|
|
256
|
+
equipment: formatEquipmentDisplayName(row.equipment, row.equipmentId),
|
|
257
|
+
id: row.id,
|
|
258
|
+
lastHistoryEntry: latestHistory[0] ?? null,
|
|
259
|
+
name: formatExerciseDisplayName(row.name, asBool(row.isUserCreated)),
|
|
260
|
+
perceptionScale: row.perceptionScale,
|
|
261
|
+
primaryMuscles: formatMuscleLabel(row.primaryMuscles),
|
|
262
|
+
recentRoutines: routineRows.map((routine) => routine.routineName ?? '(unnamed)').slice(0, historyLimit),
|
|
263
|
+
secondaryMuscles: formatMuscleLabel(row.secondaryMuscles),
|
|
264
|
+
supports1RM: asBool(row.supports1RM),
|
|
265
|
+
timerBased: asBool(row.timerBased),
|
|
266
|
+
totalRoutines: routineRows.length,
|
|
267
|
+
totalWorkouts: Number(workoutCountRow?.totalWorkouts ?? 0),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
export async function getLastPerformedExerciseSnapshot(db, exerciseId) {
|
|
271
|
+
const latestHistory = await getExerciseHistoryRows(db, exerciseId, { limit: 1 });
|
|
272
|
+
const latest = latestHistory[0];
|
|
273
|
+
if (!latest)
|
|
274
|
+
return null;
|
|
275
|
+
const workout = await getWorkoutDetail(db, latest.workoutId);
|
|
276
|
+
const exercise = workout.exercises.find((entry) => entry.exerciseId === exerciseId);
|
|
277
|
+
if (!exercise)
|
|
278
|
+
return null;
|
|
279
|
+
return { exercise, workout };
|
|
280
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Kysely } from 'kysely';
|
|
2
|
+
import { DatabaseSchema } from '../db.js';
|
|
3
|
+
import { ProgramDetailTree, ProgramSummary } from '../types.js';
|
|
4
|
+
export declare function listPrograms(db: Kysely<DatabaseSchema>): Promise<ProgramSummary[]>;
|
|
5
|
+
export declare function resolveProgramSelector(db: Kysely<DatabaseSchema>, selector: string | undefined, activeOnly: boolean): Promise<number>;
|
|
6
|
+
export declare function getProgramDetail(db: Kysely<DatabaseSchema>, programId: number): Promise<ProgramDetailTree>;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { formatExerciseDisplayName } from '../names.js';
|
|
2
|
+
import { normalizeRpe } from '../rpe.js';
|
|
3
|
+
import { appleSecondsToIso } from '../time.js';
|
|
4
|
+
import { convertKgToDisplayWeight, resolveProgramWeightUnit } from '../units.js';
|
|
5
|
+
import { resolveIdOrName } from './selectors.js';
|
|
6
|
+
function asBool(value) {
|
|
7
|
+
return value === 1;
|
|
8
|
+
}
|
|
9
|
+
export async function listPrograms(db) {
|
|
10
|
+
const selectedProgram = await db
|
|
11
|
+
.selectFrom('ZWORKOUTPROGRAMSINFO as info')
|
|
12
|
+
.innerJoin('ZWORKOUTPLAN as plan', 'plan.ZID', 'info.ZSELECTEDWORKOUTPROGRAMID')
|
|
13
|
+
.select('plan.Z_PK as id')
|
|
14
|
+
.where('info.ZSELECTEDWORKOUTPROGRAMID', 'is not', null)
|
|
15
|
+
.executeTakeFirst();
|
|
16
|
+
const rows = await db
|
|
17
|
+
.selectFrom('ZWORKOUTPLAN')
|
|
18
|
+
.select([
|
|
19
|
+
'Z_PK as id',
|
|
20
|
+
'ZNAME as name',
|
|
21
|
+
'ZISCURRENT as isActive',
|
|
22
|
+
'ZISTEMPLATE as isTemplate',
|
|
23
|
+
'ZDATEADDED as dateAdded',
|
|
24
|
+
])
|
|
25
|
+
.where('ZSOFTDELETED', 'is not', 1)
|
|
26
|
+
.orderBy('ZDATEADDED', 'desc')
|
|
27
|
+
.execute();
|
|
28
|
+
return rows.map((row) => ({
|
|
29
|
+
dateAdded: appleSecondsToIso(row.dateAdded),
|
|
30
|
+
id: row.id,
|
|
31
|
+
isActive: selectedProgram ? row.id === selectedProgram.id : asBool(row.isActive),
|
|
32
|
+
isTemplate: asBool(row.isTemplate),
|
|
33
|
+
name: row.name ?? '(unnamed)',
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
export async function resolveProgramSelector(db, selector, activeOnly) {
|
|
37
|
+
if (activeOnly) {
|
|
38
|
+
const selectedProgram = await db
|
|
39
|
+
.selectFrom('ZWORKOUTPROGRAMSINFO as info')
|
|
40
|
+
.innerJoin('ZWORKOUTPLAN as plan', 'plan.ZID', 'info.ZSELECTEDWORKOUTPROGRAMID')
|
|
41
|
+
.select('plan.Z_PK as id')
|
|
42
|
+
.where('info.ZSELECTEDWORKOUTPROGRAMID', 'is not', null)
|
|
43
|
+
.executeTakeFirst();
|
|
44
|
+
if (selectedProgram)
|
|
45
|
+
return selectedProgram.id;
|
|
46
|
+
const fallbackRows = await db
|
|
47
|
+
.selectFrom('ZWORKOUTPLAN')
|
|
48
|
+
.select(['Z_PK as id'])
|
|
49
|
+
.where('ZISCURRENT', '=', 1)
|
|
50
|
+
.execute();
|
|
51
|
+
if (fallbackRows.length !== 1) {
|
|
52
|
+
throw new Error(`Expected exactly one active program. Found ${fallbackRows.length} via ZISCURRENT and no selected program in ZWORKOUTPROGRAMSINFO.`);
|
|
53
|
+
}
|
|
54
|
+
return fallbackRows[0].id;
|
|
55
|
+
}
|
|
56
|
+
if (!selector)
|
|
57
|
+
throw new Error('Program selector is required unless --active/--current is set.');
|
|
58
|
+
const programId = await resolveIdOrName(db, 'ZWORKOUTPLAN', selector);
|
|
59
|
+
const nonDeleted = await db
|
|
60
|
+
.selectFrom('ZWORKOUTPLAN')
|
|
61
|
+
.select('Z_PK as id')
|
|
62
|
+
.where('Z_PK', '=', programId)
|
|
63
|
+
.where('ZSOFTDELETED', 'is not', 1)
|
|
64
|
+
.executeTakeFirst();
|
|
65
|
+
if (!nonDeleted)
|
|
66
|
+
throw new Error(`Program ${programId} is soft-deleted and hidden from CLI output.`);
|
|
67
|
+
return programId;
|
|
68
|
+
}
|
|
69
|
+
export async function getProgramDetail(db, programId) {
|
|
70
|
+
const unitPreference = await resolveProgramWeightUnit(db, programId);
|
|
71
|
+
const selectedProgram = await db
|
|
72
|
+
.selectFrom('ZWORKOUTPROGRAMSINFO as info')
|
|
73
|
+
.innerJoin('ZWORKOUTPLAN as plan', 'plan.ZID', 'info.ZSELECTEDWORKOUTPROGRAMID')
|
|
74
|
+
.select('plan.Z_PK as id')
|
|
75
|
+
.where('info.ZSELECTEDWORKOUTPROGRAMID', 'is not', null)
|
|
76
|
+
.executeTakeFirst();
|
|
77
|
+
const programRow = await db
|
|
78
|
+
.selectFrom('ZWORKOUTPLAN')
|
|
79
|
+
.select([
|
|
80
|
+
'Z_PK as id',
|
|
81
|
+
'ZNAME as name',
|
|
82
|
+
'ZISCURRENT as isActive',
|
|
83
|
+
'ZISTEMPLATE as isTemplate',
|
|
84
|
+
'ZDATEADDED as dateAdded',
|
|
85
|
+
])
|
|
86
|
+
.where('Z_PK', '=', programId)
|
|
87
|
+
.where('ZSOFTDELETED', 'is not', 1)
|
|
88
|
+
.executeTakeFirst();
|
|
89
|
+
if (!programRow)
|
|
90
|
+
throw new Error(`Program not found: ${programId}`);
|
|
91
|
+
const weeks = await db
|
|
92
|
+
.selectFrom('ZPERIOD')
|
|
93
|
+
.select(['Z_PK as id'])
|
|
94
|
+
.where('ZWORKOUTPLAN', '=', programId)
|
|
95
|
+
.orderBy('Z_FOK_WORKOUTPLAN', 'asc')
|
|
96
|
+
.orderBy('Z_PK', 'asc')
|
|
97
|
+
.execute();
|
|
98
|
+
const routines = await db
|
|
99
|
+
.selectFrom('ZROUTINE as r')
|
|
100
|
+
.leftJoin('ZPERIOD as p', 'p.Z_PK', 'r.ZPERIOD')
|
|
101
|
+
.select(['r.Z_PK as id', 'r.ZNAME as name', 'r.ZPERIOD as weekId'])
|
|
102
|
+
.where((eb) => eb.or([eb('p.ZWORKOUTPLAN', '=', programId), eb('r.ZWORKOUTPLAN', '=', programId)]))
|
|
103
|
+
.where('r.ZSOFTDELETED', 'is not', 1)
|
|
104
|
+
.orderBy('r.Z_FOK_PERIOD', 'asc')
|
|
105
|
+
.orderBy('r.Z_PK', 'asc')
|
|
106
|
+
.execute();
|
|
107
|
+
const exercises = await db
|
|
108
|
+
.selectFrom('ZROUTINE as r')
|
|
109
|
+
.leftJoin('ZPERIOD as p', 'p.Z_PK', 'r.ZPERIOD')
|
|
110
|
+
.leftJoin('Z_12ROUTINES as j', 'j.Z_28ROUTINES', 'r.Z_PK')
|
|
111
|
+
.leftJoin('ZEXERCISECONFIGURATION as ec', 'ec.Z_PK', 'j.Z_12EXERCISES')
|
|
112
|
+
.leftJoin('ZEXERCISEINFORMATION as ei', 'ei.Z_PK', 'ec.ZINFORMATION')
|
|
113
|
+
.select([
|
|
114
|
+
'r.Z_PK as routineId',
|
|
115
|
+
'ec.Z_PK as exerciseConfigId',
|
|
116
|
+
'j.Z_FOK_12EXERCISES as routineExerciseOrder',
|
|
117
|
+
'ec.ZSETS as plannedSets',
|
|
118
|
+
'ec.ZREPS as plannedReps',
|
|
119
|
+
'ec.ZWEIGHT as plannedWeight',
|
|
120
|
+
'ec.ZTIME as plannedTimeSeconds',
|
|
121
|
+
'ei.Z_PK as exerciseId',
|
|
122
|
+
'ei.ZISUSERCREATED as isUserCreated',
|
|
123
|
+
'ei.ZNAME as exerciseName',
|
|
124
|
+
])
|
|
125
|
+
.where((eb) => eb.or([eb('p.ZWORKOUTPLAN', '=', programId), eb('r.ZWORKOUTPLAN', '=', programId)]))
|
|
126
|
+
.where('r.ZSOFTDELETED', 'is not', 1)
|
|
127
|
+
.where('ec.Z_PK', 'is not', null)
|
|
128
|
+
.orderBy('r.Z_PK', 'asc')
|
|
129
|
+
.orderBy('j.Z_FOK_12EXERCISES', 'asc')
|
|
130
|
+
.orderBy('ec.Z_PK', 'asc')
|
|
131
|
+
.execute();
|
|
132
|
+
const exerciseConfigIds = exercises
|
|
133
|
+
.map((exercise) => exercise.exerciseConfigId)
|
|
134
|
+
.filter((value) => value !== null);
|
|
135
|
+
const setRows = exerciseConfigIds.length === 0
|
|
136
|
+
? []
|
|
137
|
+
: await db
|
|
138
|
+
.selectFrom('ZSETCONFIGURATION')
|
|
139
|
+
.select([
|
|
140
|
+
'Z_PK as id',
|
|
141
|
+
'ZEXERCISECONFIGURATION as exerciseConfigId',
|
|
142
|
+
'ZREPS as reps',
|
|
143
|
+
'ZRPE as rpe',
|
|
144
|
+
'ZWEIGHT as weight',
|
|
145
|
+
'ZTIME as timeSeconds',
|
|
146
|
+
])
|
|
147
|
+
.where('ZEXERCISECONFIGURATION', 'in', exerciseConfigIds)
|
|
148
|
+
.orderBy('ZSETINDEX', 'asc')
|
|
149
|
+
.execute();
|
|
150
|
+
const setsByExerciseConfig = new Map();
|
|
151
|
+
for (const row of setRows) {
|
|
152
|
+
if (row.exerciseConfigId === null)
|
|
153
|
+
continue;
|
|
154
|
+
const current = setsByExerciseConfig.get(row.exerciseConfigId) ?? [];
|
|
155
|
+
current.push({
|
|
156
|
+
id: row.id,
|
|
157
|
+
reps: row.reps,
|
|
158
|
+
rpe: normalizeRpe(row.rpe),
|
|
159
|
+
timeSeconds: row.timeSeconds,
|
|
160
|
+
weight: convertKgToDisplayWeight(row.weight, unitPreference),
|
|
161
|
+
});
|
|
162
|
+
setsByExerciseConfig.set(row.exerciseConfigId, current);
|
|
163
|
+
}
|
|
164
|
+
const exercisesByRoutine = new Map();
|
|
165
|
+
for (const row of exercises) {
|
|
166
|
+
if (row.exerciseConfigId === null)
|
|
167
|
+
continue;
|
|
168
|
+
const current = exercisesByRoutine.get(row.routineId) ?? [];
|
|
169
|
+
const explicitSets = setsByExerciseConfig.get(row.exerciseConfigId) ?? [];
|
|
170
|
+
const fallbackSet = explicitSets.length > 0
|
|
171
|
+
? explicitSets
|
|
172
|
+
: Array.from({ length: Math.max(row.plannedSets ?? 1, 1) }, () => ({
|
|
173
|
+
id: null,
|
|
174
|
+
reps: row.plannedReps,
|
|
175
|
+
rpe: null,
|
|
176
|
+
timeSeconds: row.plannedTimeSeconds,
|
|
177
|
+
weight: convertKgToDisplayWeight(row.plannedWeight, unitPreference),
|
|
178
|
+
}));
|
|
179
|
+
current.push({
|
|
180
|
+
exerciseConfigId: row.exerciseConfigId,
|
|
181
|
+
id: row.exerciseId,
|
|
182
|
+
name: formatExerciseDisplayName(row.exerciseName, asBool(row.isUserCreated)),
|
|
183
|
+
plannedReps: row.plannedReps,
|
|
184
|
+
plannedSets: row.plannedSets,
|
|
185
|
+
plannedTimeSeconds: row.plannedTimeSeconds,
|
|
186
|
+
plannedWeight: convertKgToDisplayWeight(row.plannedWeight, unitPreference),
|
|
187
|
+
sets: fallbackSet,
|
|
188
|
+
});
|
|
189
|
+
exercisesByRoutine.set(row.routineId, current);
|
|
190
|
+
}
|
|
191
|
+
const routinesByWeek = new Map();
|
|
192
|
+
for (const routine of routines) {
|
|
193
|
+
if (routine.weekId === null)
|
|
194
|
+
continue;
|
|
195
|
+
const current = routinesByWeek.get(routine.weekId) ?? [];
|
|
196
|
+
current.push({
|
|
197
|
+
exercises: exercisesByRoutine.get(routine.id) ?? [],
|
|
198
|
+
id: routine.id,
|
|
199
|
+
name: routine.name,
|
|
200
|
+
});
|
|
201
|
+
routinesByWeek.set(routine.weekId, current);
|
|
202
|
+
}
|
|
203
|
+
const weekTree = weeks.map((week) => ({
|
|
204
|
+
id: week.id,
|
|
205
|
+
routines: routinesByWeek.get(week.id) ?? [],
|
|
206
|
+
}));
|
|
207
|
+
return {
|
|
208
|
+
program: {
|
|
209
|
+
dateAdded: appleSecondsToIso(programRow.dateAdded),
|
|
210
|
+
id: programRow.id,
|
|
211
|
+
isActive: selectedProgram ? programRow.id === selectedProgram.id : asBool(programRow.isActive),
|
|
212
|
+
isTemplate: asBool(programRow.isTemplate),
|
|
213
|
+
name: programRow.name ?? '(unnamed)',
|
|
214
|
+
},
|
|
215
|
+
weeks: weekTree,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
function isNumeric(value) {
|
|
3
|
+
return /^\d+$/.test(value);
|
|
4
|
+
}
|
|
5
|
+
function renderCandidateList(candidates) {
|
|
6
|
+
return candidates.map((candidate) => `${candidate.id}:${candidate.name}`).join(', ');
|
|
7
|
+
}
|
|
8
|
+
const asIdName = (rows) => rows.map((row) => ({ id: row.id, name: row.name ?? '(unnamed)' }));
|
|
9
|
+
export async function resolveIdOrName(db, table, selector) {
|
|
10
|
+
if (isNumeric(selector))
|
|
11
|
+
return Number(selector);
|
|
12
|
+
const exact = await db
|
|
13
|
+
.selectFrom(table)
|
|
14
|
+
.select(['Z_PK as id', 'ZNAME as name'])
|
|
15
|
+
.where(sql `lower(ZNAME) = ${selector.toLowerCase()}`)
|
|
16
|
+
.execute();
|
|
17
|
+
if (exact.length === 1)
|
|
18
|
+
return exact[0].id;
|
|
19
|
+
if (exact.length > 1) {
|
|
20
|
+
const candidates = asIdName(exact);
|
|
21
|
+
throw new Error(`Selector "${selector}" is ambiguous: ${renderCandidateList(candidates)}`);
|
|
22
|
+
}
|
|
23
|
+
const partial = await db
|
|
24
|
+
.selectFrom(table)
|
|
25
|
+
.select(['Z_PK as id', 'ZNAME as name'])
|
|
26
|
+
.where(sql `lower(ZNAME) like ${`%${selector.toLowerCase()}%`}`)
|
|
27
|
+
.execute();
|
|
28
|
+
if (partial.length === 1)
|
|
29
|
+
return partial[0].id;
|
|
30
|
+
if (partial.length > 1) {
|
|
31
|
+
const candidates = asIdName(partial);
|
|
32
|
+
throw new Error(`Selector "${selector}" is ambiguous: ${renderCandidateList(candidates)}`);
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`No records found for selector: ${selector}`);
|
|
35
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Kysely } from 'kysely';
|
|
2
|
+
import { DatabaseSchema } from '../db.js';
|
|
3
|
+
import { WorkoutDetail, WorkoutSummary } from '../types.js';
|
|
4
|
+
export type WorkoutFilters = {
|
|
5
|
+
from?: string;
|
|
6
|
+
limit?: number;
|
|
7
|
+
on?: string;
|
|
8
|
+
program?: string;
|
|
9
|
+
routine?: string;
|
|
10
|
+
to?: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function listWorkouts(db: Kysely<DatabaseSchema>, filters: WorkoutFilters): Promise<WorkoutSummary[]>;
|
|
13
|
+
export declare function getWorkoutDetail(db: Kysely<DatabaseSchema>, workoutId: number): Promise<WorkoutDetail>;
|