incremnt 0.1.7 → 0.1.9
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 +32 -6
- package/package.json +6 -3
- package/src/mcp.js +60 -0
- package/src/openrouter.js +193 -0
- package/src/queries.js +225 -0
- package/src/remote.js +4 -0
- package/src/sync-service.js +255 -32
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# incremnt
|
|
2
2
|
|
|
3
|
-
Command-line tool for querying your [incremnt](https://incremnt.app) strength training data.
|
|
3
|
+
Command-line tool and MCP server for querying your [incremnt](https://incremnt.app) strength training data.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -8,11 +8,13 @@ Command-line tool for querying your [incremnt](https://incremnt.app) strength tr
|
|
|
8
8
|
npm install -g incremnt
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
This gives you two commands: `incremnt` (CLI) and `incremnt-mcp` (MCP server).
|
|
12
|
+
|
|
13
|
+
## CLI
|
|
12
14
|
|
|
13
15
|
### Hosted sync (recommended)
|
|
14
16
|
|
|
15
|
-
After connecting with Apple in the iOS app (Settings >
|
|
17
|
+
After connecting with Apple in the iOS app (Settings > Cloud Sync), your workouts sync automatically. To access them from the CLI:
|
|
16
18
|
|
|
17
19
|
```bash
|
|
18
20
|
incremnt login
|
|
@@ -34,7 +36,7 @@ incremnt records --input ~/Downloads/export.onemore.json --pretty
|
|
|
34
36
|
|
|
35
37
|
If `--input` is omitted, the CLI checks `INCREMNT_SNAPSHOT`, then `ONEMORE_SNAPSHOT`, then common local paths, then the most recent `.onemore.json` in `~/Downloads`.
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
### Commands
|
|
38
40
|
|
|
39
41
|
| Command | Description |
|
|
40
42
|
|---------|-------------|
|
|
@@ -49,7 +51,7 @@ If `--input` is omitted, the CLI checks `INCREMNT_SNAPSHOT`, then `ONEMORE_SNAPS
|
|
|
49
51
|
| `status` | Show current mode, auth state, and config paths |
|
|
50
52
|
| `contract` | Machine-readable command surface for scripts |
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
### Flags
|
|
53
55
|
|
|
54
56
|
| Flag | Description |
|
|
55
57
|
|------|-------------|
|
|
@@ -57,10 +59,34 @@ If `--input` is omitted, the CLI checks `INCREMNT_SNAPSHOT`, then `ONEMORE_SNAPS
|
|
|
57
59
|
| `--input <path>` | Path to a local `.onemore.json` snapshot |
|
|
58
60
|
| `--limit <n>` | Limit number of results (for `sessions list`) |
|
|
59
61
|
|
|
60
|
-
|
|
62
|
+
### Exercise matching
|
|
61
63
|
|
|
62
64
|
`exercises history --name "Bench Press"` uses canonical synonym matching, so it finds `Barbell Bench Press` without pulling in incline, machine, or dumbbell variants.
|
|
63
65
|
|
|
66
|
+
## MCP Server
|
|
67
|
+
|
|
68
|
+
The package includes an [MCP](https://modelcontextprotocol.io) server that exposes the same queries as tools for AI assistants like Claude.
|
|
69
|
+
|
|
70
|
+
### Setup
|
|
71
|
+
|
|
72
|
+
Add to your Claude Code project config (`.mcp.json`):
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"mcpServers": {
|
|
77
|
+
"incremnt": {
|
|
78
|
+
"type": "stdio",
|
|
79
|
+
"command": "npx",
|
|
80
|
+
"args": ["-y", "incremnt-mcp"]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Or run directly: `npx incremnt-mcp`
|
|
87
|
+
|
|
88
|
+
The MCP server uses the same auth session as the CLI — run `incremnt login` first.
|
|
89
|
+
|
|
64
90
|
## License
|
|
65
91
|
|
|
66
92
|
MIT
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "incremnt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Command-line tool for querying your incremnt strength training data",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
|
-
"incremnt": "./src/index.js"
|
|
8
|
+
"incremnt": "./src/index.js",
|
|
9
|
+
"incremnt-mcp": "./src/mcp.js"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"src/"
|
|
@@ -15,6 +16,8 @@
|
|
|
15
16
|
"dev:sync-fixture": "node ./scripts/dev-sync-fixture-server.js"
|
|
16
17
|
},
|
|
17
18
|
"dependencies": {
|
|
18
|
-
"
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
20
|
+
"chalk": "^5.6.2",
|
|
21
|
+
"zod": "^3.24.0"
|
|
19
22
|
}
|
|
20
23
|
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { commandSchema } from './contract.js';
|
|
7
|
+
import { readSessionState } from './state.js';
|
|
8
|
+
import { createTransport } from './transport.js';
|
|
9
|
+
|
|
10
|
+
const server = new McpServer({
|
|
11
|
+
name: 'incremnt',
|
|
12
|
+
version: '0.1.0'
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
for (const cmd of commandSchema) {
|
|
16
|
+
const shape = {};
|
|
17
|
+
|
|
18
|
+
for (const opt of cmd.options) {
|
|
19
|
+
let field = opt.type === 'number' ? z.number() : z.string();
|
|
20
|
+
if (!opt.required) field = field.optional();
|
|
21
|
+
field = field.describe(opt.description);
|
|
22
|
+
shape[opt.name] = field;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
server.tool(cmd.id, cmd.description, shape, async (args) => {
|
|
26
|
+
try {
|
|
27
|
+
const sessionState = await readSessionState();
|
|
28
|
+
const transport = await createTransport({}, sessionState);
|
|
29
|
+
|
|
30
|
+
if (transport.expired) {
|
|
31
|
+
return {
|
|
32
|
+
content: [{ type: 'text', text: 'Session expired. Run `incremnt login` to re-authenticate.' }],
|
|
33
|
+
isError: true
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const result = await transport.executeReadCommand(cmd.id, args);
|
|
38
|
+
return {
|
|
39
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
|
|
40
|
+
};
|
|
41
|
+
} catch (error) {
|
|
42
|
+
const message = error?.message ?? String(error);
|
|
43
|
+
|
|
44
|
+
if (error?.code === 'SNAPSHOT_NOT_FOUND') {
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: 'text', text: 'Not logged in. Run `incremnt login` first.' }],
|
|
47
|
+
isError: true
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
content: [{ type: 'text', text: message }],
|
|
53
|
+
isError: true
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const transport = new StdioServerTransport();
|
|
60
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
const DEFAULT_MODEL = 'meta-llama/llama-3.1-8b-instruct:free';
|
|
2
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
3
|
+
const DEFAULT_MAX_TOKENS = 300;
|
|
4
|
+
|
|
5
|
+
const SYSTEM_PROMPT = `You are a strength coach writing a brief note after reviewing a trainee's week of training. Write 2-3 short paragraphs separated by blank lines.
|
|
6
|
+
|
|
7
|
+
Rules:
|
|
8
|
+
- Reference specific exercises, weights, and reps from the data. Use numbers, not vague praise.
|
|
9
|
+
- If there are PRs, mention them matter-of-factly. Do not celebrate or inflate them.
|
|
10
|
+
- Note what went well and what lagged, with specifics.
|
|
11
|
+
- End with one concrete suggestion for next week. Be specific about which exercise and what to change.
|
|
12
|
+
- Write like a real person texting a training partner — short sentences, no filler, no cheerleading.
|
|
13
|
+
- Never use words like: impressive, crushed, solid, demonstrating, significant, incredible, fantastic, highlighting.
|
|
14
|
+
- Never use -ing clauses that add fake depth ("indicating progressive overload", "demonstrating strength gains").
|
|
15
|
+
- No bullet points or lists.`;
|
|
16
|
+
|
|
17
|
+
export async function generateCoachingSummary(cycleContext, { apiKey, model, timeoutMs } = {}) {
|
|
18
|
+
const resolvedModel = model || DEFAULT_MODEL;
|
|
19
|
+
const resolvedTimeout = timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
20
|
+
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const timer = setTimeout(() => controller.abort(), resolvedTimeout);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const userContent = formatCycleContext(cycleContext);
|
|
26
|
+
|
|
27
|
+
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: {
|
|
30
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
'HTTP-Referer': 'https://incremnt.app',
|
|
33
|
+
'X-Title': 'incremnt'
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
model: resolvedModel,
|
|
37
|
+
messages: [
|
|
38
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
39
|
+
{ role: 'user', content: userContent }
|
|
40
|
+
],
|
|
41
|
+
max_tokens: DEFAULT_MAX_TOKENS,
|
|
42
|
+
temperature: 0.7
|
|
43
|
+
}),
|
|
44
|
+
signal: controller.signal
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
const text = await response.text().catch(() => '');
|
|
49
|
+
throw new Error(`OpenRouter API error ${response.status}: ${text}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const data = await response.json();
|
|
53
|
+
const content = data.choices?.[0]?.message?.content;
|
|
54
|
+
if (!content) {
|
|
55
|
+
throw new Error('No content in OpenRouter response');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return content.trim();
|
|
59
|
+
} finally {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatCycleContext(ctx) {
|
|
65
|
+
const lines = [
|
|
66
|
+
`Program: ${ctx.programName}, Week ${ctx.cycleNumber}, ${ctx.totalSessions} session(s).`
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
for (const session of ctx.sessions) {
|
|
70
|
+
const parts = [session.dayName || 'Session'];
|
|
71
|
+
if (session.totalVolume) parts.push(`${session.totalVolume} kg volume`);
|
|
72
|
+
if (session.effortScore) parts.push(`effort ${session.effortScore}/10`);
|
|
73
|
+
lines.push(parts.join(', '));
|
|
74
|
+
|
|
75
|
+
for (const ex of session.exercises) {
|
|
76
|
+
const setPart = ex.plannedSets != null
|
|
77
|
+
? `${ex.completedSets}/${ex.plannedSets} sets`
|
|
78
|
+
: `${ex.completedSets} sets`;
|
|
79
|
+
const topPart = ex.topSet
|
|
80
|
+
? ` (top: ${ex.topSet.weight}×${ex.topSet.reps})`
|
|
81
|
+
: '';
|
|
82
|
+
lines.push(` ${ex.exerciseName}: ${setPart}${topPart}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (ctx.prsThisCycle.length > 0) {
|
|
87
|
+
lines.push('PRs this week:');
|
|
88
|
+
for (const pr of ctx.prsThisCycle) {
|
|
89
|
+
lines.push(` ${pr.exerciseName}: ${pr.weight}×${pr.reps} (e1RM ${pr.estimatedOneRM})`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (ctx.goalProgress) {
|
|
94
|
+
lines.push('Goal progress:');
|
|
95
|
+
for (const g of ctx.goalProgress) {
|
|
96
|
+
lines.push(` ${g.exerciseName}: ${g.progressPercent ?? '?'}% toward ${g.targetE1RM} e1RM`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const WORKOUT_COACH_PROMPT = `You are a strength coach writing a brief note after reviewing one training session. Write 2-3 short paragraphs separated by blank lines.
|
|
104
|
+
|
|
105
|
+
Rules:
|
|
106
|
+
- Reference specific exercises, weights, and reps from the data. Use numbers, not vague praise.
|
|
107
|
+
- If there are PRs, mention them matter-of-factly. Do not celebrate or inflate them.
|
|
108
|
+
- Compare volume or effort to recent sessions if the data is there. Just state what changed.
|
|
109
|
+
- End with one concrete suggestion for next session. Be specific about which exercise and what to try.
|
|
110
|
+
- Write like a real person texting a training partner — short sentences, no filler, no cheerleading.
|
|
111
|
+
- Never use words like: impressive, crushed, solid, demonstrating, significant, incredible, fantastic, highlighting.
|
|
112
|
+
- Never use -ing clauses that add fake depth ("indicating progressive overload", "demonstrating strength gains").
|
|
113
|
+
- No bullet points or lists.`;
|
|
114
|
+
|
|
115
|
+
export async function generateWorkoutCoachingSummary(workoutContext, { apiKey, model, timeoutMs } = {}) {
|
|
116
|
+
const resolvedModel = model || DEFAULT_MODEL;
|
|
117
|
+
const resolvedTimeout = timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
118
|
+
|
|
119
|
+
const controller = new AbortController();
|
|
120
|
+
const timer = setTimeout(() => controller.abort(), resolvedTimeout);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const userContent = formatWorkoutContext(workoutContext);
|
|
124
|
+
|
|
125
|
+
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: {
|
|
128
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
129
|
+
'Content-Type': 'application/json',
|
|
130
|
+
'HTTP-Referer': 'https://incremnt.app',
|
|
131
|
+
'X-Title': 'incremnt'
|
|
132
|
+
},
|
|
133
|
+
body: JSON.stringify({
|
|
134
|
+
model: resolvedModel,
|
|
135
|
+
messages: [
|
|
136
|
+
{ role: 'system', content: WORKOUT_COACH_PROMPT },
|
|
137
|
+
{ role: 'user', content: userContent }
|
|
138
|
+
],
|
|
139
|
+
max_tokens: DEFAULT_MAX_TOKENS,
|
|
140
|
+
temperature: 0.7
|
|
141
|
+
}),
|
|
142
|
+
signal: controller.signal
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
const text = await response.text().catch(() => '');
|
|
147
|
+
throw new Error(`OpenRouter API error ${response.status}: ${text}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const data = await response.json();
|
|
151
|
+
const content = data.choices?.[0]?.message?.content;
|
|
152
|
+
if (!content) {
|
|
153
|
+
throw new Error('No content in OpenRouter response');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return content.trim();
|
|
157
|
+
} finally {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function formatWorkoutContext(ctx) {
|
|
163
|
+
const lines = [
|
|
164
|
+
`Session: ${ctx.dayName}, ${ctx.sessionDate}, ${ctx.totalVolume} kg total volume.`
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
if (ctx.effortScore) {
|
|
168
|
+
lines.push(`Effort rating: ${ctx.effortScore}/10.`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
lines.push('Exercises:');
|
|
172
|
+
for (const ex of ctx.exercises) {
|
|
173
|
+
const topPart = ex.topSet ? ` (top: ${ex.topSet.weight}×${ex.topSet.reps})` : '';
|
|
174
|
+
lines.push(` ${ex.exerciseName}: ${ex.completedSets} sets${topPart}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (ctx.prs.length > 0) {
|
|
178
|
+
lines.push('PRs this session:');
|
|
179
|
+
for (const pr of ctx.prs) {
|
|
180
|
+
lines.push(` ${pr.exerciseName}: ${pr.weight}×${pr.reps} (e1RM ${pr.estimatedOneRM})`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (ctx.recentComparisons.length > 0) {
|
|
185
|
+
lines.push('Recent same-day sessions for comparison:');
|
|
186
|
+
for (const comp of ctx.recentComparisons) {
|
|
187
|
+
const effort = comp.effortScore ? `, effort ${comp.effortScore}/10` : '';
|
|
188
|
+
lines.push(` ${comp.date}: ${comp.totalVolume} kg volume${effort}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return lines.join('\n');
|
|
193
|
+
}
|
package/src/queries.js
CHANGED
|
@@ -347,6 +347,231 @@ export function goalDetail(snapshot, planId) {
|
|
|
347
347
|
};
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
+
export function cycleSummaryContext(snapshot, programId) {
|
|
351
|
+
const programs = snapshot.programs ?? [];
|
|
352
|
+
const program = programId
|
|
353
|
+
? programs.find((p) => p.id === programId)
|
|
354
|
+
: programs.find((p) => p.id === snapshot.activeProgramId) ?? programs[0];
|
|
355
|
+
|
|
356
|
+
if (!program) return null;
|
|
357
|
+
|
|
358
|
+
const completedCycles = Number(program.completedCyclesCount ?? 0);
|
|
359
|
+
if (completedCycles === 0) return null;
|
|
360
|
+
|
|
361
|
+
const cycleWeekNumber = completedCycles;
|
|
362
|
+
|
|
363
|
+
const cycleSessions = (snapshot.sessions ?? [])
|
|
364
|
+
.filter(
|
|
365
|
+
(s) =>
|
|
366
|
+
s.programId === program.id &&
|
|
367
|
+
s.historicalContext?.programWeekNumber === cycleWeekNumber
|
|
368
|
+
)
|
|
369
|
+
.sort((a, b) =>
|
|
370
|
+
String(completionDateForSession(a)).localeCompare(
|
|
371
|
+
String(completionDateForSession(b))
|
|
372
|
+
)
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
if (cycleSessions.length === 0) return null;
|
|
376
|
+
|
|
377
|
+
const priorSessions = (snapshot.sessions ?? []).filter(
|
|
378
|
+
(s) =>
|
|
379
|
+
s.programId === program.id &&
|
|
380
|
+
(s.historicalContext?.programWeekNumber ?? 0) < cycleWeekNumber
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
const priorBests = new Map();
|
|
384
|
+
for (const session of priorSessions) {
|
|
385
|
+
for (const exercise of session.exercises ?? []) {
|
|
386
|
+
for (const set of exercise.sets ?? []) {
|
|
387
|
+
if (!set.isComplete) continue;
|
|
388
|
+
const key = normalizeExerciseName(exercise.name);
|
|
389
|
+
const score = Number(set.weight) * (1 + Number(set.reps) / 30);
|
|
390
|
+
const current = priorBests.get(key);
|
|
391
|
+
if (!current || score > current) {
|
|
392
|
+
priorBests.set(key, score);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const prsThisCycle = [];
|
|
399
|
+
|
|
400
|
+
const sessions = cycleSessions.map((session) => {
|
|
401
|
+
const plannedByExercise = new Map(
|
|
402
|
+
(session.prescriptionSnapshot?.exercises ?? []).map((e) => [
|
|
403
|
+
normalizeExerciseName(e.exerciseName),
|
|
404
|
+
e
|
|
405
|
+
])
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
const exercises = (session.exercises ?? []).map((exercise) => {
|
|
409
|
+
const key = normalizeExerciseName(exercise.name);
|
|
410
|
+
const planned = plannedByExercise.get(key);
|
|
411
|
+
const completeSets = (exercise.sets ?? []).filter((s) => s.isComplete);
|
|
412
|
+
|
|
413
|
+
for (const set of completeSets) {
|
|
414
|
+
const score = Number(set.weight) * (1 + Number(set.reps) / 30);
|
|
415
|
+
const prior = priorBests.get(key);
|
|
416
|
+
if (!prior || score > prior) {
|
|
417
|
+
prsThisCycle.push({
|
|
418
|
+
exerciseName: exercise.name,
|
|
419
|
+
weight: set.weight,
|
|
420
|
+
reps: set.reps,
|
|
421
|
+
estimatedOneRM: Math.round(score * 10) / 10
|
|
422
|
+
});
|
|
423
|
+
priorBests.set(key, score);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
exerciseName: exercise.name,
|
|
429
|
+
muscleGroup: exercise.muscleGroup ?? null,
|
|
430
|
+
completedSets: completeSets.length,
|
|
431
|
+
plannedSets: planned?.targetSets?.length ?? null,
|
|
432
|
+
topSet: completeSets.length > 0
|
|
433
|
+
? completeSets.reduce((best, s) => {
|
|
434
|
+
const score = Number(s.weight) * (1 + Number(s.reps) / 30);
|
|
435
|
+
const bestScore =
|
|
436
|
+
Number(best.weight) * (1 + Number(best.reps) / 30);
|
|
437
|
+
return score > bestScore ? s : best;
|
|
438
|
+
})
|
|
439
|
+
: null
|
|
440
|
+
};
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
sessionDate: completionDateForSession(session),
|
|
445
|
+
dayName: session.dayName ?? null,
|
|
446
|
+
totalVolume: session.summary?.totalVolume ?? session.volume ?? 0,
|
|
447
|
+
effortScore: session.summary?.effortScore ?? null,
|
|
448
|
+
averageHeartRate: session.summary?.averageHeartRate ?? null,
|
|
449
|
+
exercises
|
|
450
|
+
};
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
let goalProgress = null;
|
|
454
|
+
const plans = snapshot.strengthPlans ?? [];
|
|
455
|
+
const activePlan = plans.find(
|
|
456
|
+
(p) => p.status === 'active' && p.programId === program.id
|
|
457
|
+
);
|
|
458
|
+
if (activePlan) {
|
|
459
|
+
goalProgress = (activePlan.liftGoals ?? []).map((g) => {
|
|
460
|
+
const range = g.targetE1RM - g.startingE1RM;
|
|
461
|
+
const gained = g.currentBestE1RM - g.startingE1RM;
|
|
462
|
+
const progressPct =
|
|
463
|
+
range > 0 ? Math.round((gained / range) * 100) : null;
|
|
464
|
+
return {
|
|
465
|
+
exerciseName: g.exerciseDisplayName,
|
|
466
|
+
progressPercent: progressPct,
|
|
467
|
+
currentBestE1RM: g.currentBestE1RM,
|
|
468
|
+
targetE1RM: g.targetE1RM
|
|
469
|
+
};
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
programName: program.name,
|
|
475
|
+
cycleNumber: cycleWeekNumber,
|
|
476
|
+
totalSessions: cycleSessions.length,
|
|
477
|
+
sessions,
|
|
478
|
+
prsThisCycle,
|
|
479
|
+
goalProgress
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export function workoutSummaryContext(snapshot, sessionId) {
|
|
484
|
+
const sessions = snapshot.sessions ?? [];
|
|
485
|
+
const session = sessions.find((s) => s.id === sessionId);
|
|
486
|
+
if (!session) return null;
|
|
487
|
+
|
|
488
|
+
const sessionDate = completionDateForSession(session);
|
|
489
|
+
const dayName = session.dayName ?? 'Session';
|
|
490
|
+
|
|
491
|
+
// Build exercise details
|
|
492
|
+
const exercises = (session.exercises ?? []).map((exercise) => {
|
|
493
|
+
const completeSets = (exercise.sets ?? []).filter((s) => s.isComplete);
|
|
494
|
+
const topSet = completeSets.length > 0
|
|
495
|
+
? completeSets.reduce((best, s) => {
|
|
496
|
+
const score = Number(s.weight) * (1 + Number(s.reps) / 30);
|
|
497
|
+
const bestScore = Number(best.weight) * (1 + Number(best.reps) / 30);
|
|
498
|
+
return score > bestScore ? s : best;
|
|
499
|
+
})
|
|
500
|
+
: null;
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
exerciseName: exercise.name,
|
|
504
|
+
muscleGroup: exercise.muscleGroup ?? null,
|
|
505
|
+
completedSets: completeSets.length,
|
|
506
|
+
topSet: topSet ? { weight: topSet.weight, reps: topSet.reps } : null
|
|
507
|
+
};
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// Find recent sessions with same dayName for comparison (up to 3, excluding current)
|
|
511
|
+
const recentComparisons = sessions
|
|
512
|
+
.filter(
|
|
513
|
+
(s) =>
|
|
514
|
+
s.id !== sessionId &&
|
|
515
|
+
s.dayName === dayName &&
|
|
516
|
+
s.programId === session.programId
|
|
517
|
+
)
|
|
518
|
+
.sort((a, b) => String(completionDateForSession(b)).localeCompare(String(completionDateForSession(a))))
|
|
519
|
+
.slice(0, 3)
|
|
520
|
+
.map((s) => ({
|
|
521
|
+
date: completionDateForSession(s),
|
|
522
|
+
totalVolume: s.summary?.totalVolume ?? s.volume ?? 0,
|
|
523
|
+
effortScore: s.summary?.effortScore ?? null
|
|
524
|
+
}));
|
|
525
|
+
|
|
526
|
+
// Detect PRs — compare each exercise's best e1RM against all prior sessions
|
|
527
|
+
const priorBests = new Map();
|
|
528
|
+
for (const s of sessions) {
|
|
529
|
+
if (s.id === sessionId) continue;
|
|
530
|
+
const sDate = String(completionDateForSession(s));
|
|
531
|
+
const currentDate = String(sessionDate);
|
|
532
|
+
if (sDate >= currentDate) continue;
|
|
533
|
+
|
|
534
|
+
for (const exercise of s.exercises ?? []) {
|
|
535
|
+
for (const set of exercise.sets ?? []) {
|
|
536
|
+
if (!set.isComplete) continue;
|
|
537
|
+
const key = normalizeExerciseName(exercise.name);
|
|
538
|
+
const score = Number(set.weight) * (1 + Number(set.reps) / 30);
|
|
539
|
+
const current = priorBests.get(key);
|
|
540
|
+
if (!current || score > current) priorBests.set(key, score);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const prs = [];
|
|
546
|
+
for (const exercise of session.exercises ?? []) {
|
|
547
|
+
const key = normalizeExerciseName(exercise.name);
|
|
548
|
+
for (const set of exercise.sets ?? []) {
|
|
549
|
+
if (!set.isComplete) continue;
|
|
550
|
+
const score = Number(set.weight) * (1 + Number(set.reps) / 30);
|
|
551
|
+
const prior = priorBests.get(key);
|
|
552
|
+
if (!prior || score > prior) {
|
|
553
|
+
prs.push({
|
|
554
|
+
exerciseName: exercise.name,
|
|
555
|
+
weight: set.weight,
|
|
556
|
+
reps: set.reps,
|
|
557
|
+
estimatedOneRM: Math.round(score * 10) / 10
|
|
558
|
+
});
|
|
559
|
+
priorBests.set(key, score);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
sessionDate,
|
|
566
|
+
dayName,
|
|
567
|
+
totalVolume: session.summary?.totalVolume ?? session.volume ?? 0,
|
|
568
|
+
effortScore: session.summary?.effortScore ?? null,
|
|
569
|
+
exercises,
|
|
570
|
+
recentComparisons,
|
|
571
|
+
prs
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
350
575
|
function requiredOption(options, primaryKey, legacyKey = null) {
|
|
351
576
|
return options[primaryKey] ?? (legacyKey ? options[legacyKey] : undefined);
|
|
352
577
|
}
|
package/src/remote.js
CHANGED
|
@@ -118,6 +118,10 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
|
|
|
118
118
|
}
|
|
119
119
|
case 'records':
|
|
120
120
|
return resolveServiceUrl(baseUrl, '/cli/records');
|
|
121
|
+
case 'goals-list':
|
|
122
|
+
return resolveServiceUrl(baseUrl, '/cli/goals');
|
|
123
|
+
case 'goals-show':
|
|
124
|
+
return resolveServiceUrl(baseUrl, options.id ? `/cli/goals/${options.id}` : '/cli/goals');
|
|
121
125
|
default:
|
|
122
126
|
return resolveServiceUrl(baseUrl, '/');
|
|
123
127
|
}
|
package/src/sync-service.js
CHANGED
|
@@ -5,6 +5,8 @@ import { executeReadCommand } from './queries.js';
|
|
|
5
5
|
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
6
6
|
const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
7
7
|
const DEFAULT_RATE_LIMIT_RULES = {
|
|
8
|
+
'workout-summary-ai': 3,
|
|
9
|
+
'cycle-summary-ai': 3,
|
|
8
10
|
'dev-login': 10,
|
|
9
11
|
'device-start': 20,
|
|
10
12
|
'device-poll': 300,
|
|
@@ -17,6 +19,7 @@ const DEFAULT_RATE_LIMIT_RULES = {
|
|
|
17
19
|
'web-auth-callback': 20,
|
|
18
20
|
'session-login': 60,
|
|
19
21
|
'session-refresh': 30,
|
|
22
|
+
'sync-account-preferences': 30,
|
|
20
23
|
'proposals': 30,
|
|
21
24
|
'proposal-update': 30
|
|
22
25
|
};
|
|
@@ -197,6 +200,10 @@ function routeRequest(url) {
|
|
|
197
200
|
return { command: 'sync-upload', options: {} };
|
|
198
201
|
}
|
|
199
202
|
|
|
203
|
+
if (pathname === '/sync/account/preferences') {
|
|
204
|
+
return { command: 'sync-account-preferences', options: {} };
|
|
205
|
+
}
|
|
206
|
+
|
|
200
207
|
if (pathname === '/cli/sessions') {
|
|
201
208
|
return {
|
|
202
209
|
command: 'session-insights',
|
|
@@ -284,6 +291,24 @@ function routeRequest(url) {
|
|
|
284
291
|
};
|
|
285
292
|
}
|
|
286
293
|
|
|
294
|
+
if (pathname === '/cli/workout-summary/ai') {
|
|
295
|
+
return {
|
|
296
|
+
command: 'workout-summary-ai',
|
|
297
|
+
options: {
|
|
298
|
+
'session-id': url.searchParams.get('session-id') ?? undefined
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (pathname === '/cli/cycle-summary/ai') {
|
|
304
|
+
return {
|
|
305
|
+
command: 'cycle-summary-ai',
|
|
306
|
+
options: {
|
|
307
|
+
'program-id': url.searchParams.get('program-id') ?? undefined
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
287
312
|
return null;
|
|
288
313
|
}
|
|
289
314
|
|
|
@@ -350,41 +375,67 @@ function deviceApprovalPage({
|
|
|
350
375
|
const escapedUserCode = escapeHtml(userCode);
|
|
351
376
|
const escapedEmail = escapeHtml(email);
|
|
352
377
|
const escapedUserId = escapeHtml(userId);
|
|
353
|
-
const
|
|
354
|
-
const
|
|
378
|
+
const badgeBg = isError ? 'rgba(255,69,58,0.15)' : 'rgba(0,255,163,0.1)';
|
|
379
|
+
const badgeColor = isError ? '#FF453A' : '#00ffa3';
|
|
355
380
|
|
|
356
381
|
return `<!doctype html>
|
|
357
382
|
<html lang="en">
|
|
358
383
|
<head>
|
|
359
384
|
<meta charset="utf-8" />
|
|
360
385
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
361
|
-
<title>${escapedTitle}</title>
|
|
386
|
+
<title>${escapedTitle} - INCREMNT</title>
|
|
387
|
+
<link rel="icon" type="image/png" href="https://www.incremnt.app/assets/images/favicon.png">
|
|
388
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
389
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
390
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
|
|
362
391
|
<style>
|
|
363
392
|
:root {
|
|
364
|
-
color-scheme:
|
|
365
|
-
font-family: -apple-system, BlinkMacSystemFont,
|
|
393
|
+
color-scheme: dark;
|
|
394
|
+
font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
|
|
366
395
|
}
|
|
367
396
|
body {
|
|
368
397
|
margin: 0;
|
|
369
398
|
min-height: 100vh;
|
|
370
399
|
display: grid;
|
|
371
400
|
place-items: center;
|
|
372
|
-
background:
|
|
373
|
-
color: #
|
|
401
|
+
background: #000000;
|
|
402
|
+
color: #ffffff;
|
|
374
403
|
}
|
|
375
404
|
main {
|
|
376
405
|
width: min(92vw, 28rem);
|
|
377
|
-
background:
|
|
406
|
+
background: #121212;
|
|
378
407
|
border-radius: 24px;
|
|
379
|
-
|
|
408
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
380
409
|
padding: 1.5rem;
|
|
381
410
|
}
|
|
411
|
+
.logo {
|
|
412
|
+
display: flex;
|
|
413
|
+
align-items: center;
|
|
414
|
+
gap: 0.6rem;
|
|
415
|
+
margin-bottom: 1.2rem;
|
|
416
|
+
}
|
|
417
|
+
.logo img {
|
|
418
|
+
width: 32px;
|
|
419
|
+
height: 32px;
|
|
420
|
+
border-radius: 8px;
|
|
421
|
+
}
|
|
422
|
+
.logo-text {
|
|
423
|
+
font-size: 1.1rem;
|
|
424
|
+
font-weight: 800;
|
|
425
|
+
letter-spacing: 0.02em;
|
|
426
|
+
}
|
|
427
|
+
.logo-gradient {
|
|
428
|
+
background: linear-gradient(to right, #00ffa3, #3b82f6);
|
|
429
|
+
-webkit-background-clip: text;
|
|
430
|
+
-webkit-text-fill-color: transparent;
|
|
431
|
+
background-clip: text;
|
|
432
|
+
}
|
|
382
433
|
.badge {
|
|
383
434
|
display: inline-block;
|
|
384
435
|
padding: 0.35rem 0.65rem;
|
|
385
436
|
border-radius: 999px;
|
|
386
|
-
background: ${
|
|
387
|
-
color: ${
|
|
437
|
+
background: ${badgeBg};
|
|
438
|
+
color: ${badgeColor};
|
|
388
439
|
font-size: 0.85rem;
|
|
389
440
|
font-weight: 600;
|
|
390
441
|
}
|
|
@@ -395,7 +446,7 @@ function deviceApprovalPage({
|
|
|
395
446
|
}
|
|
396
447
|
p {
|
|
397
448
|
margin: 0 0 1rem;
|
|
398
|
-
color: #
|
|
449
|
+
color: #a1a1a6;
|
|
399
450
|
}
|
|
400
451
|
form {
|
|
401
452
|
display: grid;
|
|
@@ -411,42 +462,61 @@ function deviceApprovalPage({
|
|
|
411
462
|
text-align: center;
|
|
412
463
|
text-decoration: none;
|
|
413
464
|
border-radius: 14px;
|
|
414
|
-
background:
|
|
415
|
-
color: #
|
|
416
|
-
border: 1px solid
|
|
465
|
+
background: rgba(255,255,255,0.1);
|
|
466
|
+
color: #ffffff;
|
|
467
|
+
border: 1px solid rgba(255,255,255,0.2);
|
|
417
468
|
font-weight: 700;
|
|
418
469
|
padding: 0.9rem 1rem;
|
|
470
|
+
transition: background 0.15s, border-color 0.15s;
|
|
471
|
+
}
|
|
472
|
+
.oauth-link:hover {
|
|
473
|
+
background: rgba(255,255,255,0.15);
|
|
474
|
+
border-color: rgba(255,255,255,0.3);
|
|
419
475
|
}
|
|
420
476
|
label {
|
|
421
477
|
display: grid;
|
|
422
478
|
gap: 0.35rem;
|
|
423
479
|
font-size: 0.95rem;
|
|
424
480
|
font-weight: 600;
|
|
481
|
+
color: #ffffff;
|
|
425
482
|
}
|
|
426
483
|
input {
|
|
427
|
-
border: 1px solid
|
|
484
|
+
border: 1px solid rgba(255,255,255,0.2);
|
|
428
485
|
border-radius: 12px;
|
|
429
486
|
padding: 0.8rem 0.9rem;
|
|
430
487
|
font: inherit;
|
|
488
|
+
background: rgba(255,255,255,0.05);
|
|
489
|
+
color: #ffffff;
|
|
490
|
+
}
|
|
491
|
+
input::placeholder {
|
|
492
|
+
color: #a1a1a6;
|
|
431
493
|
}
|
|
432
494
|
button {
|
|
433
495
|
margin-top: 0.4rem;
|
|
434
496
|
border: 0;
|
|
435
497
|
border-radius: 14px;
|
|
436
|
-
background: #
|
|
437
|
-
color:
|
|
498
|
+
background: linear-gradient(to right, #00ffa3, #3b82f6);
|
|
499
|
+
color: #000000;
|
|
438
500
|
font: inherit;
|
|
439
501
|
font-weight: 700;
|
|
440
502
|
padding: 0.9rem 1rem;
|
|
503
|
+
cursor: pointer;
|
|
441
504
|
}
|
|
442
505
|
small {
|
|
443
|
-
color: #
|
|
506
|
+
color: #a1a1a6;
|
|
507
|
+
}
|
|
508
|
+
code {
|
|
509
|
+
color: #00ffa3;
|
|
444
510
|
}
|
|
445
511
|
</style>
|
|
446
512
|
</head>
|
|
447
513
|
<body>
|
|
448
514
|
<main>
|
|
449
|
-
<
|
|
515
|
+
<div class="logo">
|
|
516
|
+
<img src="https://www.incremnt.app/assets/images/favicon.png" alt="" />
|
|
517
|
+
<span class="logo-text">INCRE<span class="logo-gradient">MNT</span></span>
|
|
518
|
+
</div>
|
|
519
|
+
<span class="badge">device login</span>
|
|
450
520
|
<h1>${escapedTitle}</h1>
|
|
451
521
|
<p>${escapedMessage}</p>
|
|
452
522
|
${(appleStartPath || googleStartPath) ? `
|
|
@@ -487,31 +557,68 @@ function deviceApprovalSuccessPage({ email, userId }) {
|
|
|
487
557
|
<head>
|
|
488
558
|
<meta charset="utf-8" />
|
|
489
559
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
490
|
-
<title>Connected</title>
|
|
560
|
+
<title>Connected - INCREMNT</title>
|
|
561
|
+
<link rel="icon" type="image/png" href="https://www.incremnt.app/assets/images/favicon.png">
|
|
562
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
563
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
564
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
|
|
491
565
|
<style>
|
|
492
566
|
:root {
|
|
493
|
-
color-scheme:
|
|
494
|
-
font-family: -apple-system, BlinkMacSystemFont,
|
|
567
|
+
color-scheme: dark;
|
|
568
|
+
font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
|
|
495
569
|
}
|
|
496
570
|
body {
|
|
497
571
|
margin: 0;
|
|
498
572
|
min-height: 100vh;
|
|
499
573
|
display: grid;
|
|
500
574
|
place-items: center;
|
|
501
|
-
background:
|
|
502
|
-
color: #
|
|
575
|
+
background: #000000;
|
|
576
|
+
color: #ffffff;
|
|
503
577
|
}
|
|
504
578
|
main {
|
|
505
579
|
width: min(92vw, 28rem);
|
|
506
|
-
background:
|
|
580
|
+
background: #121212;
|
|
507
581
|
border-radius: 24px;
|
|
508
|
-
|
|
582
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
509
583
|
padding: 2rem 1.5rem;
|
|
510
584
|
text-align: center;
|
|
511
585
|
}
|
|
586
|
+
.logo {
|
|
587
|
+
display: flex;
|
|
588
|
+
align-items: center;
|
|
589
|
+
justify-content: center;
|
|
590
|
+
gap: 0.6rem;
|
|
591
|
+
margin-bottom: 1.2rem;
|
|
592
|
+
}
|
|
593
|
+
.logo img {
|
|
594
|
+
width: 32px;
|
|
595
|
+
height: 32px;
|
|
596
|
+
border-radius: 8px;
|
|
597
|
+
}
|
|
598
|
+
.logo-text {
|
|
599
|
+
font-size: 1.1rem;
|
|
600
|
+
font-weight: 800;
|
|
601
|
+
letter-spacing: 0.02em;
|
|
602
|
+
}
|
|
603
|
+
.logo-gradient {
|
|
604
|
+
background: linear-gradient(to right, #00ffa3, #3b82f6);
|
|
605
|
+
-webkit-background-clip: text;
|
|
606
|
+
-webkit-text-fill-color: transparent;
|
|
607
|
+
background-clip: text;
|
|
608
|
+
}
|
|
512
609
|
.checkmark {
|
|
513
|
-
|
|
514
|
-
|
|
610
|
+
width: 48px;
|
|
611
|
+
height: 48px;
|
|
612
|
+
border-radius: 50%;
|
|
613
|
+
background: rgba(0,255,163,0.15);
|
|
614
|
+
display: flex;
|
|
615
|
+
align-items: center;
|
|
616
|
+
justify-content: center;
|
|
617
|
+
margin: 0 auto 1rem;
|
|
618
|
+
}
|
|
619
|
+
.checkmark svg {
|
|
620
|
+
width: 24px;
|
|
621
|
+
height: 24px;
|
|
515
622
|
}
|
|
516
623
|
h1 {
|
|
517
624
|
margin: 0 0 0.5rem;
|
|
@@ -520,13 +627,21 @@ function deviceApprovalSuccessPage({ email, userId }) {
|
|
|
520
627
|
}
|
|
521
628
|
p {
|
|
522
629
|
margin: 0;
|
|
523
|
-
color: #
|
|
630
|
+
color: #a1a1a6;
|
|
524
631
|
}
|
|
525
632
|
</style>
|
|
526
633
|
</head>
|
|
527
634
|
<body>
|
|
528
635
|
<main>
|
|
529
|
-
<div class="
|
|
636
|
+
<div class="logo">
|
|
637
|
+
<img src="https://www.incremnt.app/assets/images/favicon.png" alt="" />
|
|
638
|
+
<span class="logo-text">INCRE<span class="logo-gradient">MNT</span></span>
|
|
639
|
+
</div>
|
|
640
|
+
<div class="checkmark">
|
|
641
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="#00ffa3" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
|
642
|
+
<polyline points="20 6 9 17 4 12"></polyline>
|
|
643
|
+
</svg>
|
|
644
|
+
</div>
|
|
530
645
|
<h1>You're all set</h1>
|
|
531
646
|
<p>Signed in as ${displayName}. You can close this tab and return to your terminal.</p>
|
|
532
647
|
</main>
|
|
@@ -663,7 +778,8 @@ export function createSyncServiceRequestHandler({
|
|
|
663
778
|
corsOrigins = [],
|
|
664
779
|
createProposalForAccount = null,
|
|
665
780
|
listProposalsForAccount = null,
|
|
666
|
-
updateProposalForAccount = null
|
|
781
|
+
updateProposalForAccount = null,
|
|
782
|
+
updateAnalysisConsentForAccount = null
|
|
667
783
|
}) {
|
|
668
784
|
const rateLimiter = createRateLimiter(rateLimitConfig ?? {});
|
|
669
785
|
|
|
@@ -1475,6 +1591,49 @@ export function createSyncServiceRequestHandler({
|
|
|
1475
1591
|
}
|
|
1476
1592
|
}
|
|
1477
1593
|
|
|
1594
|
+
if (route.command === 'sync-account-preferences') {
|
|
1595
|
+
if (request.method !== 'PUT' && request.method !== 'PATCH') {
|
|
1596
|
+
methodNotAllowed(response, 'Use PUT or PATCH for /sync/account/preferences.');
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
if (!updateAnalysisConsentForAccount) {
|
|
1601
|
+
methodNotAllowed(response, 'Account preference updates are not enabled for this service mode.');
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
const account = writeAuthenticator
|
|
1606
|
+
? await writeAuthenticator(requestToken)
|
|
1607
|
+
: requestToken === token
|
|
1608
|
+
? { id: 'remote-user', email: null }
|
|
1609
|
+
: null;
|
|
1610
|
+
if (!account) {
|
|
1611
|
+
unauthorized(response, request);
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
try {
|
|
1616
|
+
const body = await readJsonBody(request);
|
|
1617
|
+
if (typeof body.analysisConsentEnabled !== 'boolean') {
|
|
1618
|
+
badRequest(response, 'analysisConsentEnabled must be a boolean.');
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
const updatedAccount = await updateAnalysisConsentForAccount(account, {
|
|
1623
|
+
enabled: body.analysisConsentEnabled,
|
|
1624
|
+
version: body.consentVersion ?? 1
|
|
1625
|
+
});
|
|
1626
|
+
json(response, 200, {
|
|
1627
|
+
ok: true,
|
|
1628
|
+
account: updatedAccount
|
|
1629
|
+
});
|
|
1630
|
+
return;
|
|
1631
|
+
} catch (error) {
|
|
1632
|
+
badRequest(response, error.message);
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1478
1637
|
const account = readAuthenticator
|
|
1479
1638
|
? await readAuthenticator(requestToken)
|
|
1480
1639
|
: requestToken === token
|
|
@@ -1493,6 +1652,70 @@ export function createSyncServiceRequestHandler({
|
|
|
1493
1652
|
} catch {
|
|
1494
1653
|
snapshot = { sessions: [], programs: [], activeProgramId: null };
|
|
1495
1654
|
}
|
|
1655
|
+
if (route.command === 'workout-summary-ai') {
|
|
1656
|
+
const sessionId = route.options['session-id'];
|
|
1657
|
+
if (!sessionId) {
|
|
1658
|
+
badRequest(response, 'session-id query parameter is required');
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
const { workoutSummaryContext } = await import('./queries.js');
|
|
1663
|
+
const ctx = workoutSummaryContext(snapshot, sessionId);
|
|
1664
|
+
if (!ctx) {
|
|
1665
|
+
notFound(response, `Session not found: ${sessionId}`);
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
const openrouterKey = process.env.OPENROUTER_API_KEY;
|
|
1670
|
+
if (!openrouterKey) {
|
|
1671
|
+
json(response, 503, { error: 'AI summaries not configured' });
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
try {
|
|
1676
|
+
const { generateWorkoutCoachingSummary } = await import('./openrouter.js');
|
|
1677
|
+
const model = process.env.OPENROUTER_MODEL || undefined;
|
|
1678
|
+
const summary = await generateWorkoutCoachingSummary(ctx, { apiKey: openrouterKey, model });
|
|
1679
|
+
json(response, 200, { summary });
|
|
1680
|
+
} catch (err) {
|
|
1681
|
+
console.error('AI workout summary error:', err.message);
|
|
1682
|
+
json(response, 502, { error: 'Failed to generate AI summary' });
|
|
1683
|
+
}
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
if (route.command === 'cycle-summary-ai') {
|
|
1688
|
+
const programId = route.options['program-id'];
|
|
1689
|
+
if (!programId) {
|
|
1690
|
+
badRequest(response, 'program-id query parameter is required');
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
const { cycleSummaryContext } = await import('./queries.js');
|
|
1695
|
+
const ctx = cycleSummaryContext(snapshot, programId);
|
|
1696
|
+
if (!ctx) {
|
|
1697
|
+
notFound(response, `No completed cycle found for program: ${programId}`);
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
const openrouterKey = process.env.OPENROUTER_API_KEY;
|
|
1702
|
+
if (!openrouterKey) {
|
|
1703
|
+
json(response, 503, { error: 'AI summaries not configured' });
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
try {
|
|
1708
|
+
const { generateCoachingSummary } = await import('./openrouter.js');
|
|
1709
|
+
const model = process.env.OPENROUTER_MODEL || undefined;
|
|
1710
|
+
const summary = await generateCoachingSummary(ctx, { apiKey: openrouterKey, model });
|
|
1711
|
+
json(response, 200, { summary });
|
|
1712
|
+
} catch (err) {
|
|
1713
|
+
console.error('AI cycle summary error:', err.message);
|
|
1714
|
+
json(response, 502, { error: 'Failed to generate AI summary' });
|
|
1715
|
+
}
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1496
1719
|
const result = executeReadCommand(snapshot, route.command, route.options);
|
|
1497
1720
|
if (!result.ok) {
|
|
1498
1721
|
if (result.error.startsWith('Session not found')) {
|