catence 0.0.0 → 0.2.0-beta.1
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/LICENSE +661 -0
- package/README.md +201 -29
- package/config.example.json +63 -0
- package/dist/contracts/index.d.ts +4 -0
- package/dist/contracts/index.js +5 -0
- package/dist/contracts/release.d.ts +20 -0
- package/dist/contracts/release.js +17 -0
- package/dist/contracts/runtime.d.ts +11 -0
- package/dist/contracts/runtime.js +2 -0
- package/dist/contracts/staging.d.ts +183 -0
- package/dist/contracts/staging.js +36 -0
- package/dist/contracts/storage.d.ts +15 -0
- package/dist/contracts/storage.js +2 -0
- package/dist/core/dashboard/snapshot.d.ts +16 -0
- package/dist/core/dashboard/snapshot.js +65 -0
- package/dist/core/query/activity-discovery.d.ts +21 -0
- package/dist/core/query/activity-discovery.js +133 -0
- package/dist/core/query/analytics.d.ts +59 -0
- package/dist/core/query/analytics.js +395 -0
- package/dist/core/query/catalog.d.ts +26 -0
- package/dist/core/query/catalog.js +141 -0
- package/dist/core/query/fitness.d.ts +40 -0
- package/dist/core/query/fitness.js +297 -0
- package/dist/core/query/repository.d.ts +19 -0
- package/dist/core/query/repository.js +182 -0
- package/dist/core/query/sql-guard.d.ts +7 -0
- package/dist/core/query/sql-guard.js +121 -0
- package/dist/core/query/sql.d.ts +2 -0
- package/dist/core/query/sql.js +5 -0
- package/dist/core/query/swimming.d.ts +19 -0
- package/dist/core/query/swimming.js +184 -0
- package/dist/core/query/wellness.d.ts +45 -0
- package/dist/core/query/wellness.js +292 -0
- package/dist/core/retrieval/index.d.ts +13 -0
- package/dist/core/retrieval/index.js +248 -0
- package/dist/core/runtime/catalog.d.ts +41 -0
- package/dist/core/runtime/catalog.js +111 -0
- package/dist/core/runtime/configuration.d.ts +71 -0
- package/dist/core/runtime/configuration.js +106 -0
- package/dist/core/runtime/limiter.d.ts +13 -0
- package/dist/core/runtime/limiter.js +17 -0
- package/dist/core/runtime/secrets.d.ts +24 -0
- package/dist/core/runtime/secrets.js +68 -0
- package/dist/elt/application/demo.d.ts +23 -0
- package/dist/elt/application/demo.js +165 -0
- package/dist/elt/application/management.d.ts +25 -0
- package/dist/elt/application/management.js +285 -0
- package/dist/elt/ingestion/importer.d.ts +8 -0
- package/dist/elt/ingestion/importer.js +87 -0
- package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
- package/dist/elt/ingestion/providers/intervals.js +224 -0
- package/dist/elt/ingestion/providers/registry.d.ts +19 -0
- package/dist/elt/ingestion/providers/registry.js +62 -0
- package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
- package/dist/elt/ingestion/providers/strava/service.js +366 -0
- package/dist/elt/normalization/activities/linking.d.ts +5 -0
- package/dist/elt/normalization/activities/linking.js +165 -0
- package/dist/elt/normalization/normalizers.d.ts +5 -0
- package/dist/elt/normalization/normalizers.js +816 -0
- package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
- package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
- package/dist/elt/storage/artifacts.d.ts +13 -0
- package/dist/elt/storage/artifacts.js +48 -0
- package/dist/elt/storage/database.d.ts +74 -0
- package/dist/elt/storage/database.js +296 -0
- package/dist/elt/storage/migrations.d.ts +3 -0
- package/dist/elt/storage/migrations.js +832 -0
- package/dist/elt/storage/migrations.legacy.d.ts +12 -0
- package/dist/elt/storage/migrations.legacy.js +332 -0
- package/dist/elt/storage/sql.d.ts +2 -0
- package/dist/elt/storage/sql.js +7 -0
- package/dist/elt/storage/write-lock.d.ts +6 -0
- package/dist/elt/storage/write-lock.js +28 -0
- package/dist/elt/streams.d.ts +24 -0
- package/dist/elt/streams.js +62 -0
- package/dist/interfaces/cli/commands.d.ts +2 -0
- package/dist/interfaces/cli/commands.js +166 -0
- package/dist/interfaces/cli/main.d.ts +2 -0
- package/dist/interfaces/cli/main.js +3 -0
- package/dist/interfaces/http/cli-options.d.ts +9 -0
- package/dist/interfaces/http/cli-options.js +81 -0
- package/dist/interfaces/http/server.d.ts +17 -0
- package/dist/interfaces/http/server.js +196 -0
- package/dist/interfaces/mcp/cli-options.d.ts +6 -0
- package/dist/interfaces/mcp/cli-options.js +36 -0
- package/dist/interfaces/mcp/main.d.ts +2 -0
- package/dist/interfaces/mcp/main.js +66 -0
- package/dist/interfaces/mcp/server.d.ts +8 -0
- package/dist/interfaces/mcp/server.js +572 -0
- package/dist/runtime/index.d.ts +33 -0
- package/dist/runtime/index.js +21 -0
- package/docs/architecture.md +100 -0
- package/docs/assets/image.png +0 -0
- package/docs/mcp-activity-retrieval.md +377 -0
- package/package.json +81 -6
- package/pyproject.toml +28 -0
- package/python/__init__.py +1 -0
- package/python/catence/__init__.py +0 -0
- package/python/catence/providers/__init__.py +0 -0
- package/python/catence/providers/garmin/__init__.py +3 -0
- package/python/catence/providers/garmin/cli.py +62 -0
- package/python/catence/providers/garmin/contracts.py +23 -0
- package/python/catence/providers/garmin/registry.py +48 -0
- package/python/catence/providers/garmin/staging.py +87 -0
- package/python/catence/providers/garmin/streams.py +195 -0
- package/python/catence/providers/garmin/worker.py +450 -0
- package/python/catence/providers/strava/__init__.py +3 -0
- package/python/catence/providers/strava/cli.py +404 -0
- package/python/catence/providers/strava/staging.py +73 -0
- package/uv.lock +708 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ReadOnlyRepository } from './repository.js';
|
|
2
|
+
export type FitnessDateRange = {
|
|
3
|
+
startDate?: string;
|
|
4
|
+
endDate?: string;
|
|
5
|
+
};
|
|
6
|
+
export type FtpHistoryRequest = FitnessDateRange & {
|
|
7
|
+
sport?: string;
|
|
8
|
+
sourcePreference?: 'settings' | 'settings_then_activity' | 'all';
|
|
9
|
+
};
|
|
10
|
+
export type Vo2MaxHistoryRequest = FitnessDateRange & {
|
|
11
|
+
sport?: string;
|
|
12
|
+
};
|
|
13
|
+
export type PowerSportFamily = 'cycling' | 'running';
|
|
14
|
+
type PowerSportSelection = {
|
|
15
|
+
sport?: string;
|
|
16
|
+
sportFamily?: PowerSportFamily;
|
|
17
|
+
};
|
|
18
|
+
export type PowerCurveTrendRequest = FitnessDateRange & PowerSportSelection & {
|
|
19
|
+
durations?: number[];
|
|
20
|
+
sourceQuality?: 'all' | 'garmin_fit_derived';
|
|
21
|
+
};
|
|
22
|
+
export type PowerCoverageReportRequest = FitnessDateRange & PowerSportSelection & {
|
|
23
|
+
sourceQuality?: 'all' | 'garmin_fit_derived';
|
|
24
|
+
};
|
|
25
|
+
export type LatestCyclingActivitiesRequest = FitnessDateRange & {
|
|
26
|
+
includeMultisport?: boolean;
|
|
27
|
+
limit?: number;
|
|
28
|
+
};
|
|
29
|
+
/** Read-only, source-aware fitness projections built on normalized facts. */
|
|
30
|
+
export declare class FitnessService {
|
|
31
|
+
private readonly repository;
|
|
32
|
+
constructor(repository: ReadOnlyRepository);
|
|
33
|
+
ftpHistory(request?: FtpHistoryRequest): Promise<Record<string, unknown>>;
|
|
34
|
+
vo2MaxHistory(request?: Vo2MaxHistoryRequest): Promise<Record<string, unknown>>;
|
|
35
|
+
powerCurveTrend(request?: PowerCurveTrendRequest): Promise<Record<string, unknown>>;
|
|
36
|
+
powerCoverageReport(request?: PowerCoverageReportRequest): Promise<Record<string, unknown>>;
|
|
37
|
+
latestCyclingActivities(request?: LatestCyclingActivitiesRequest): Promise<Record<string, unknown>>;
|
|
38
|
+
cyclingProgressReport(request?: FitnessDateRange): Promise<Record<string, unknown>>;
|
|
39
|
+
}
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { QueryValidationError } from './catalog.js';
|
|
2
|
+
const CYCLING_SPORTS = ['cycling', 'road_biking', 'indoor_cycling', 'virtual_ride'];
|
|
3
|
+
const DEFAULT_POWER_DURATIONS = [5, 60, 300, 1_200, 1_800];
|
|
4
|
+
const RUNNING_VO2MAX_ALIASES = new Set(['run', 'running']);
|
|
5
|
+
function addDateRange(column, request, values) {
|
|
6
|
+
const clauses = [];
|
|
7
|
+
if (request.startDate) {
|
|
8
|
+
values.startDate = request.startDate;
|
|
9
|
+
clauses.push(`${column} >= cast($startDate AS TIMESTAMPTZ)`);
|
|
10
|
+
}
|
|
11
|
+
if (request.endDate) {
|
|
12
|
+
values.endDate = `${request.endDate}T23:59:59.999Z`;
|
|
13
|
+
clauses.push(`${column} <= cast($endDate AS TIMESTAMPTZ)`);
|
|
14
|
+
}
|
|
15
|
+
return clauses;
|
|
16
|
+
}
|
|
17
|
+
function cyclingSportClause(column, sport, values) {
|
|
18
|
+
if (!sport || sport.toLowerCase() === 'cycling')
|
|
19
|
+
return `lower(${column}) IN (${CYCLING_SPORTS.map((item) => `'${item}'`).join(', ')})`;
|
|
20
|
+
values.sport = sport;
|
|
21
|
+
return `lower(${column}) = lower($sport)`;
|
|
22
|
+
}
|
|
23
|
+
function powerSportClause(column, selection, values) {
|
|
24
|
+
if (selection.sport && selection.sportFamily)
|
|
25
|
+
throw new QueryValidationError('Choose either sport or sportFamily, not both.');
|
|
26
|
+
if (selection.sport) {
|
|
27
|
+
values.sport = selection.sport;
|
|
28
|
+
return `lower(${column}) = lower($sport)`;
|
|
29
|
+
}
|
|
30
|
+
if (selection.sportFamily === 'cycling')
|
|
31
|
+
return `lower(${column}) IN (${CYCLING_SPORTS.map((item) => `'${item}'`).join(', ')})`;
|
|
32
|
+
if (selection.sportFamily === 'running')
|
|
33
|
+
return `lower(${column}) LIKE '%run%'`;
|
|
34
|
+
throw new QueryValidationError('power queries require an explicit sport or sportFamily.');
|
|
35
|
+
}
|
|
36
|
+
function durationLabel(duration) {
|
|
37
|
+
if (duration < 60)
|
|
38
|
+
return `${duration} s`;
|
|
39
|
+
if (duration % 60 === 0)
|
|
40
|
+
return `${duration / 60} min`;
|
|
41
|
+
return `${Math.floor(duration / 60)} min ${duration % 60} s`;
|
|
42
|
+
}
|
|
43
|
+
function sourceRank(sourceType) {
|
|
44
|
+
if (sourceType === 'cycling_ftp_history')
|
|
45
|
+
return 0;
|
|
46
|
+
if (sourceType === 'cycling_ftp')
|
|
47
|
+
return 1;
|
|
48
|
+
if (sourceType === 'activity_summary')
|
|
49
|
+
return 2;
|
|
50
|
+
return 3;
|
|
51
|
+
}
|
|
52
|
+
function sourceTypeFilter(preference) {
|
|
53
|
+
if (preference === 'settings')
|
|
54
|
+
return "AND source_type IN ('cycling_ftp_history', 'cycling_ftp')";
|
|
55
|
+
if (preference === 'settings_then_activity')
|
|
56
|
+
return "AND source_type IN ('cycling_ftp_history', 'cycling_ftp', 'activity_summary')";
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
/** Read-only, source-aware fitness projections built on normalized facts. */
|
|
60
|
+
export class FitnessService {
|
|
61
|
+
repository;
|
|
62
|
+
constructor(repository) {
|
|
63
|
+
this.repository = repository;
|
|
64
|
+
}
|
|
65
|
+
async ftpHistory(request = {}) {
|
|
66
|
+
const sourcePreference = request.sourcePreference ?? 'settings_then_activity';
|
|
67
|
+
const values = {};
|
|
68
|
+
const clauses = ["metric_name = 'cycling_ftp_w'", cyclingSportClause('sport', request.sport ?? 'cycling', values), ...addDateRange('observed_at', request, values)];
|
|
69
|
+
const observations = await this.repository.rows(`
|
|
70
|
+
SELECT observation_id, cast(observed_at AS VARCHAR) AS observed_at, value_number, value_text, unit, sport,
|
|
71
|
+
source_type, source_remote_id, activity_source_id, raw_object_hash
|
|
72
|
+
FROM training_metric_observations
|
|
73
|
+
WHERE ${clauses.join(' AND ')} ${sourceTypeFilter(sourcePreference)}
|
|
74
|
+
ORDER BY observed_at ASC,
|
|
75
|
+
CASE source_type WHEN 'cycling_ftp_history' THEN 0 WHEN 'cycling_ftp' THEN 1 WHEN 'activity_summary' THEN 2 ELSE 3 END,
|
|
76
|
+
observation_id ASC
|
|
77
|
+
`, values);
|
|
78
|
+
const seriesByDate = new Map();
|
|
79
|
+
for (const observation of observations) {
|
|
80
|
+
const date = observation.observed_at.slice(0, 10);
|
|
81
|
+
const current = seriesByDate.get(date);
|
|
82
|
+
if (!current || sourceRank(observation.source_type) < sourceRank(current.source_type))
|
|
83
|
+
seriesByDate.set(date, observation);
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
data: { observations, preferredSeries: [...seriesByDate.values()] },
|
|
87
|
+
provenance: { dataset: 'training_metric_observations', metric: 'cycling_ftp_w', sourcePreference },
|
|
88
|
+
query: { ...request, sport: request.sport ?? 'cycling', sourcePreference },
|
|
89
|
+
caveats: [
|
|
90
|
+
'FTP settings, the direct current-setting endpoint, and activity-summary FTP are retained as distinct source types.',
|
|
91
|
+
...(observations.length === 0 ? ['No matching normalized FTP observations are available for this range.'] : []),
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
async vo2MaxHistory(request = {}) {
|
|
96
|
+
if (!request.sport) {
|
|
97
|
+
const values = {};
|
|
98
|
+
const clauses = ["metric_name = 'vo2_max_ml_kg_min'", ...addDateRange('observed_at', request, values)];
|
|
99
|
+
const availableSports = await this.repository.rows(`
|
|
100
|
+
WITH ranked AS (
|
|
101
|
+
SELECT sport, value_number, observed_at,
|
|
102
|
+
count(*) OVER (PARTITION BY sport)::INTEGER AS count,
|
|
103
|
+
row_number() OVER (PARTITION BY sport ORDER BY observed_at DESC, observation_id DESC) AS latest_rank
|
|
104
|
+
FROM training_metric_observations
|
|
105
|
+
WHERE ${clauses.join(' AND ')}
|
|
106
|
+
)
|
|
107
|
+
SELECT sport, max(count)::INTEGER AS count,
|
|
108
|
+
max(value_number) FILTER (WHERE latest_rank = 1) AS latest,
|
|
109
|
+
cast(max(observed_at) FILTER (WHERE latest_rank = 1) AS VARCHAR) AS latest_observed_at
|
|
110
|
+
FROM ranked GROUP BY sport ORDER BY sport ASC
|
|
111
|
+
`, values);
|
|
112
|
+
return {
|
|
113
|
+
data: {
|
|
114
|
+
availableSports,
|
|
115
|
+
sportAliases: [{ requestedSport: 'running', sourceSport: 'generic', reason: 'Garmin labels its running VO₂max estimate as generic.' }],
|
|
116
|
+
actionRequired: 'Choose a sport explicitly. Use running for Garmin running VO₂max.',
|
|
117
|
+
},
|
|
118
|
+
provenance: { dataset: 'training_metric_observations', metric: 'vo2_max_ml_kg_min' },
|
|
119
|
+
query: { ...request, sport: null },
|
|
120
|
+
caveats: [
|
|
121
|
+
'No VO₂max observations are returned until sport is chosen explicitly; Catence never silently defaults to cycling.',
|
|
122
|
+
'For a running request, Catence reads Garmin’s generic source-labelled VO₂max series and retains generic on each returned observation.',
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const requestedSport = request.sport;
|
|
127
|
+
const sourceSport = RUNNING_VO2MAX_ALIASES.has(requestedSport.trim().toLowerCase()) ? 'generic' : requestedSport;
|
|
128
|
+
const values = { sourceSport };
|
|
129
|
+
const clauses = ["metric_name = 'vo2_max_ml_kg_min'", 'lower(sport) = lower($sourceSport)', ...addDateRange('observed_at', request, values)];
|
|
130
|
+
const observations = await this.repository.rows(`
|
|
131
|
+
SELECT observation_id, cast(observed_at AS VARCHAR) AS observed_at, value_number, value_text, unit, sport,
|
|
132
|
+
source_type, source_remote_id, activity_source_id, raw_object_hash
|
|
133
|
+
FROM training_metric_observations
|
|
134
|
+
WHERE ${clauses.join(' AND ')}
|
|
135
|
+
ORDER BY observed_at ASC, observation_id ASC
|
|
136
|
+
`, values);
|
|
137
|
+
return {
|
|
138
|
+
data: observations,
|
|
139
|
+
provenance: { dataset: 'training_metric_observations', metric: 'vo2_max_ml_kg_min', requestedSport, sourceSport },
|
|
140
|
+
query: { ...request, sport: requestedSport, sourceSport },
|
|
141
|
+
caveats: [
|
|
142
|
+
'Sport values on observations are reported exactly as Garmin supplied them; generic and cycling values are never combined.',
|
|
143
|
+
...(sourceSport === 'generic' && requestedSport !== 'generic' ? ['Garmin supplies running VO₂max under the generic source label; the returned rows preserve that raw label.'] : []),
|
|
144
|
+
...(observations.length === 0 ? [`No normalized ${requestedSport} VO₂max history is available for this range.`] : []),
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
async powerCurveTrend(request = {}) {
|
|
149
|
+
const durations = [...new Set(request.durations ?? DEFAULT_POWER_DURATIONS)].sort((left, right) => left - right);
|
|
150
|
+
if (durations.length === 0 || durations.length > 12 || durations.some((duration) => !Number.isInteger(duration) || duration < 1 || duration > 86_400)) {
|
|
151
|
+
throw new QueryValidationError('durations must contain 1–12 whole-second values between 1 and 86,400.');
|
|
152
|
+
}
|
|
153
|
+
const sourceQuality = request.sourceQuality ?? 'all';
|
|
154
|
+
const values = {};
|
|
155
|
+
const durationPlaceholders = durations.map((duration, index) => {
|
|
156
|
+
const key = `duration${index}`;
|
|
157
|
+
values[key] = duration;
|
|
158
|
+
return `$${key}`;
|
|
159
|
+
});
|
|
160
|
+
const clauses = [powerSportClause('sport', request, values), `duration_s IN (${durationPlaceholders.join(', ')})`, ...addDateRange('started_at_utc', request, values)];
|
|
161
|
+
if (sourceQuality !== 'all')
|
|
162
|
+
clauses.push("source_type = 'garmin_fit_derived'");
|
|
163
|
+
const rows = await this.repository.rows(`
|
|
164
|
+
WITH ranked AS (
|
|
165
|
+
SELECT cast(date_trunc('month', started_at_utc) AS VARCHAR) AS month, duration_s, best_power_w,
|
|
166
|
+
activity_id, activity_source_id, cast(started_at_utc AS VARCHAR) AS started_at_utc, sport, source_type, raw_object_hash,
|
|
167
|
+
row_number() OVER (
|
|
168
|
+
PARTITION BY date_trunc('month', started_at_utc), duration_s
|
|
169
|
+
ORDER BY best_power_w DESC, started_at_utc ASC, activity_source_id ASC
|
|
170
|
+
) AS source_rank
|
|
171
|
+
FROM power_best_facts
|
|
172
|
+
WHERE ${clauses.join(' AND ')}
|
|
173
|
+
)
|
|
174
|
+
SELECT * EXCLUDE (source_rank) FROM ranked WHERE source_rank = 1
|
|
175
|
+
ORDER BY month ASC, duration_s ASC
|
|
176
|
+
`, values);
|
|
177
|
+
return {
|
|
178
|
+
data: rows.map((row) => ({ ...row, durationLabel: durationLabel(row.duration_s) })),
|
|
179
|
+
provenance: { dataset: 'power_best_facts', sourceQuality },
|
|
180
|
+
query: { ...request, durations, sourceQuality, aggregation: 'monthly maximum per duration' },
|
|
181
|
+
caveats: [
|
|
182
|
+
'Values are duration-labelled Garmin FIT-derived power bests; each month/duration row identifies its supporting activity.',
|
|
183
|
+
...(rows.length === 0 ? ['No normalized power-best observations matched the requested durations and range.'] : []),
|
|
184
|
+
],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
async powerCoverageReport(request = {}) {
|
|
188
|
+
const sourceQuality = request.sourceQuality ?? 'garmin_fit_derived';
|
|
189
|
+
const values = {};
|
|
190
|
+
const activityClauses = [powerSportClause('activity.sport', request, values), ...addDateRange('activity.started_at_utc', request, values)];
|
|
191
|
+
const bestClause = sourceQuality === 'garmin_fit_derived' ? "WHERE best.source_type = 'garmin_fit_derived'" : '';
|
|
192
|
+
const scoped = `
|
|
193
|
+
WITH source_activities AS (
|
|
194
|
+
SELECT source.activity_source_id, activity.started_at_utc, activity.sport
|
|
195
|
+
FROM activity_sources AS source
|
|
196
|
+
JOIN activities AS activity USING (activity_id)
|
|
197
|
+
WHERE ${activityClauses.join(' AND ')}
|
|
198
|
+
), scoped_bests AS (
|
|
199
|
+
SELECT best.activity_source_id, best.duration_s, best.best_power_w, best.source_type
|
|
200
|
+
FROM activity_power_bests AS best
|
|
201
|
+
JOIN source_activities USING (activity_source_id)
|
|
202
|
+
${bestClause}
|
|
203
|
+
)
|
|
204
|
+
`;
|
|
205
|
+
const [activityCoverage, durationInventory] = await Promise.all([
|
|
206
|
+
this.repository.rows(`${scoped}
|
|
207
|
+
SELECT sport, count(DISTINCT source_activities.activity_source_id)::INTEGER AS activities,
|
|
208
|
+
count(DISTINCT scoped_bests.activity_source_id)::INTEGER AS powered_activities,
|
|
209
|
+
count(scoped_bests.duration_s)::INTEGER AS datapoints,
|
|
210
|
+
cast(min(started_at_utc) AS VARCHAR) AS first_activity_at,
|
|
211
|
+
cast(max(started_at_utc) AS VARCHAR) AS last_activity_at
|
|
212
|
+
FROM source_activities
|
|
213
|
+
LEFT JOIN scoped_bests USING (activity_source_id)
|
|
214
|
+
GROUP BY sport
|
|
215
|
+
ORDER BY sport`, values),
|
|
216
|
+
this.repository.rows(`${scoped}
|
|
217
|
+
SELECT duration_s, count(*)::INTEGER AS datapoints,
|
|
218
|
+
count(DISTINCT activity_source_id)::INTEGER AS activities,
|
|
219
|
+
min(best_power_w) AS min_power_w, max(best_power_w) AS max_power_w
|
|
220
|
+
FROM scoped_bests
|
|
221
|
+
GROUP BY duration_s
|
|
222
|
+
ORDER BY duration_s`, values),
|
|
223
|
+
]);
|
|
224
|
+
return {
|
|
225
|
+
data: { activityCoverage, durationInventory: durationInventory.map((row) => ({ ...row, durationLabel: durationLabel(row.duration_s) })) },
|
|
226
|
+
provenance: { activityDataset: 'activities', powerDataset: 'power_best_facts', sourceQuality },
|
|
227
|
+
query: { ...request, sourceQuality },
|
|
228
|
+
caveats: [
|
|
229
|
+
'Coverage is based on FIT-derived per-activity duration bests, never sparse activity-summary average power.',
|
|
230
|
+
'Short-duration maxima can include sensor spikes; inspect the supporting activity stream before treating an extreme as physiological.',
|
|
231
|
+
'Raw activity samples remain scoped to one activity source at a time and may paginate.',
|
|
232
|
+
],
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
async latestCyclingActivities(request = {}) {
|
|
236
|
+
const limit = Math.min(Math.max(request.limit ?? 20, 1), 100);
|
|
237
|
+
const values = { includeMultisport: request.includeMultisport ?? false, limit };
|
|
238
|
+
const sportClause = cyclingSportClause('activity.sport', 'cycling', values);
|
|
239
|
+
const clauses = [`(${sportClause} OR ($includeMultisport AND lower(activity.sport) = 'multi_sport'))`, ...addDateRange('activity.started_at_utc', request, values)];
|
|
240
|
+
const rows = await this.repository.rows(`
|
|
241
|
+
SELECT source.activity_id, source.activity_source_id, source.remote_activity_id,
|
|
242
|
+
cast(activity.started_at_utc AS VARCHAR) AS started_at_utc, activity.sport, activity.name, activity.link_state,
|
|
243
|
+
raw.parent_remote_id, summary.distance_m, summary.moving_s, summary.elapsed_s, summary.elevation_gain_m,
|
|
244
|
+
summary.avg_hr, summary.avg_power, summary.training_load, manifest.row_count AS stream_rows, source.raw_object_hash
|
|
245
|
+
FROM activity_sources AS source
|
|
246
|
+
JOIN activities AS activity USING (activity_id)
|
|
247
|
+
LEFT JOIN activity_summaries AS summary USING (activity_source_id)
|
|
248
|
+
LEFT JOIN source_entities AS raw ON raw.provider = source.provider AND raw.entity_type = 'activity' AND raw.remote_id = source.remote_activity_id
|
|
249
|
+
LEFT JOIN stream_manifest AS manifest ON manifest.provider = source.provider AND manifest.activity_source_id = source.activity_source_id
|
|
250
|
+
WHERE source.provider = 'garmin' AND ${clauses.join(' AND ')}
|
|
251
|
+
ORDER BY activity.started_at_utc DESC, source.activity_source_id ASC
|
|
252
|
+
LIMIT $limit
|
|
253
|
+
`, values);
|
|
254
|
+
return {
|
|
255
|
+
data: rows.map((row) => ({
|
|
256
|
+
...row,
|
|
257
|
+
isMultisportParent: row.sport.toLowerCase() === 'multi_sport',
|
|
258
|
+
parentActivityRemoteId: row.parent_remote_id,
|
|
259
|
+
streamAvailable: Number(row.stream_rows ?? 0) > 0,
|
|
260
|
+
})),
|
|
261
|
+
provenance: { relations: ['activities', 'activity_sources', 'activity_summaries', 'source_entities', 'stream_manifest'], provider: 'garmin' },
|
|
262
|
+
query: { ...request, includeMultisport: request.includeMultisport ?? false, limit },
|
|
263
|
+
caveats: [
|
|
264
|
+
'The projection uses Garmin source records only, avoiding duplicate Intervals summaries for the same linked activity.',
|
|
265
|
+
...(request.includeMultisport ? ['Multisport parent summaries are flagged and are not represented as cycling-only component metrics.'] : []),
|
|
266
|
+
],
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
async cyclingProgressReport(request = {}) {
|
|
270
|
+
const values = {};
|
|
271
|
+
const clauses = [cyclingSportClause('sport', 'cycling', values), ...addDateRange('started_at_utc', request, values)];
|
|
272
|
+
const [ftp, vo2max, powerCurve, monthlyVolume] = await Promise.all([
|
|
273
|
+
this.ftpHistory({ ...request, sport: 'cycling' }),
|
|
274
|
+
this.vo2MaxHistory({ ...request, sport: 'cycling' }),
|
|
275
|
+
this.powerCurveTrend({ ...request, sport: 'cycling' }),
|
|
276
|
+
this.repository.rows(`
|
|
277
|
+
SELECT cast(date_trunc('month', started_at_utc) AS VARCHAR) AS month,
|
|
278
|
+
count(*)::INTEGER AS activities, sum(distance_m) AS distance_m, sum(moving_s) AS moving_s,
|
|
279
|
+
sum(training_load) AS training_load
|
|
280
|
+
FROM canonical_activity_training
|
|
281
|
+
WHERE ${clauses.join(' AND ')}
|
|
282
|
+
GROUP BY date_trunc('month', started_at_utc)
|
|
283
|
+
ORDER BY month ASC
|
|
284
|
+
`, values),
|
|
285
|
+
]);
|
|
286
|
+
return {
|
|
287
|
+
data: { ftp, vo2max, powerCurve, monthlyVolume },
|
|
288
|
+
provenance: { relations: ['training_metric_observations', 'power_best_facts', 'canonical_activity_training'] },
|
|
289
|
+
query: request,
|
|
290
|
+
caveats: [
|
|
291
|
+
'This is a descriptive, source-aware report—not a physiological performance model.',
|
|
292
|
+
'FTP settings, VO₂max snapshots, volume/load, and power bests retain their individual provenance in the nested results.',
|
|
293
|
+
],
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
//# sourceMappingURL=fitness.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CatencePaths } from '../../contracts/runtime.js';
|
|
2
|
+
import type { QueryValues, ReadDataStore } from '../../contracts/storage.js';
|
|
3
|
+
import { type DatasetDefinition } from './catalog.js';
|
|
4
|
+
export declare class ReadOnlyRepository {
|
|
5
|
+
readonly database: ReadDataStore;
|
|
6
|
+
readonly paths: CatencePaths;
|
|
7
|
+
constructor(database: ReadDataStore, paths: CatencePaths);
|
|
8
|
+
close(): Promise<void>;
|
|
9
|
+
rows<T extends Record<string, unknown>>(sql: string, values?: QueryValues): Promise<T[]>;
|
|
10
|
+
datasetRelation(datasetName: string): Promise<string>;
|
|
11
|
+
activitySampleRelation(): Promise<string>;
|
|
12
|
+
status(): Promise<Record<string, unknown>>;
|
|
13
|
+
coverage(): Promise<Record<string, unknown>>;
|
|
14
|
+
activity(activityId: string): Promise<Record<string, unknown> | null>;
|
|
15
|
+
summary(startDate: string, endDate: string): Promise<Record<string, unknown>>;
|
|
16
|
+
catalog(): DatasetDefinition[];
|
|
17
|
+
}
|
|
18
|
+
/** Converts DuckDB's Date/BigInt values into MCP-safe JSON values. */
|
|
19
|
+
export declare function jsonSafe<T>(value: T): T;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { sqlString } from './sql.js';
|
|
4
|
+
import { DATASET_CATALOG, getDataset } from './catalog.js';
|
|
5
|
+
export class ReadOnlyRepository {
|
|
6
|
+
database;
|
|
7
|
+
paths;
|
|
8
|
+
constructor(database, paths) {
|
|
9
|
+
this.database = database;
|
|
10
|
+
this.paths = paths;
|
|
11
|
+
}
|
|
12
|
+
async close() {
|
|
13
|
+
await this.database.close();
|
|
14
|
+
}
|
|
15
|
+
async rows(sql, values) {
|
|
16
|
+
return this.database.rows(sql, values);
|
|
17
|
+
}
|
|
18
|
+
async datasetRelation(datasetName) {
|
|
19
|
+
const dataset = getDataset(datasetName);
|
|
20
|
+
if (!dataset.samplesOnly)
|
|
21
|
+
return dataset.relation;
|
|
22
|
+
return this.activitySampleRelation();
|
|
23
|
+
}
|
|
24
|
+
async activitySampleRelation() {
|
|
25
|
+
const manifests = await this.rows(`
|
|
26
|
+
SELECT relative_path
|
|
27
|
+
FROM stream_manifest
|
|
28
|
+
QUALIFY row_number() OVER (PARTITION BY provider, activity_source_id ORDER BY content_hash DESC) = 1
|
|
29
|
+
`);
|
|
30
|
+
const hasReadableRegisteredStream = manifests.some((manifest) => {
|
|
31
|
+
const candidate = path.resolve(this.paths.root, manifest.relative_path);
|
|
32
|
+
const expectedRoot = `${path.resolve(this.paths.lake)}${path.sep}`;
|
|
33
|
+
return candidate.startsWith(expectedRoot) && candidate.endsWith('.parquet') && existsSync(candidate);
|
|
34
|
+
});
|
|
35
|
+
if (hasReadableRegisteredStream) {
|
|
36
|
+
// A UNION ALL branch for every stream reaches DuckDB's expression-depth
|
|
37
|
+
// limit around 1,000 activities. The glob is internal and rooted at the
|
|
38
|
+
// configured lake; the manifest join remains the authoritative allowlist
|
|
39
|
+
// and supplies stable provider/content-hash provenance.
|
|
40
|
+
const lakeGlob = path.join(this.paths.lake, '**', '*.parquet');
|
|
41
|
+
return `
|
|
42
|
+
SELECT manifest.provider AS provider,
|
|
43
|
+
manifest.content_hash AS content_hash,
|
|
44
|
+
sample.activity_source_id,
|
|
45
|
+
try_cast(sample.timestamp_utc AS TIMESTAMPTZ) AS timestamp_utc,
|
|
46
|
+
try_cast(sample.elapsed_s AS DOUBLE) AS elapsed_s,
|
|
47
|
+
try_cast(sample.distance_m AS DOUBLE) AS distance_m,
|
|
48
|
+
try_cast(sample.latitude AS DOUBLE) AS latitude,
|
|
49
|
+
try_cast(sample.longitude AS DOUBLE) AS longitude,
|
|
50
|
+
try_cast(sample.altitude_m AS DOUBLE) AS altitude_m,
|
|
51
|
+
try_cast(sample.heart_rate_bpm AS DOUBLE) AS heart_rate_bpm,
|
|
52
|
+
try_cast(sample.power_w AS DOUBLE) AS power_w,
|
|
53
|
+
try_cast(sample.cadence_rpm AS DOUBLE) AS cadence_rpm,
|
|
54
|
+
try_cast(sample.speed_mps AS DOUBLE) AS speed_mps,
|
|
55
|
+
try_cast(sample.temperature_c AS DOUBLE) AS temperature_c,
|
|
56
|
+
try_cast(sample.grade_pct AS DOUBLE) AS grade_pct,
|
|
57
|
+
try_cast(coalesce(json_extract_string(sample.extras_json, '$.leftRightBalance'), json_extract_string(sample.extras_json, '$.left_right_balance')) AS DOUBLE) AS left_right_balance_pct,
|
|
58
|
+
try_cast(coalesce(json_extract_string(sample.extras_json, '$.leftTorqueEffectiveness'), json_extract_string(sample.extras_json, '$.left_torque_effectiveness')) AS DOUBLE) AS left_torque_effectiveness_pct,
|
|
59
|
+
try_cast(coalesce(json_extract_string(sample.extras_json, '$.rightTorqueEffectiveness'), json_extract_string(sample.extras_json, '$.right_torque_effectiveness')) AS DOUBLE) AS right_torque_effectiveness_pct,
|
|
60
|
+
try_cast(coalesce(json_extract_string(sample.extras_json, '$.leftPedalSmoothness'), json_extract_string(sample.extras_json, '$.left_pedal_smoothness')) AS DOUBLE) AS left_pedal_smoothness_pct,
|
|
61
|
+
try_cast(coalesce(json_extract_string(sample.extras_json, '$.rightPedalSmoothness'), json_extract_string(sample.extras_json, '$.right_pedal_smoothness')) AS DOUBLE) AS right_pedal_smoothness_pct,
|
|
62
|
+
try_cast(coalesce(json_extract_string(sample.extras_json, '$.leftPowerPhase[0]'), json_extract_string(sample.extras_json, '$.left_power_phase[0]')) AS DOUBLE) AS left_power_phase_start_deg,
|
|
63
|
+
try_cast(coalesce(json_extract_string(sample.extras_json, '$.leftPowerPhase[1]'), json_extract_string(sample.extras_json, '$.left_power_phase[1]')) AS DOUBLE) AS left_power_phase_end_deg,
|
|
64
|
+
try_cast(coalesce(json_extract_string(sample.extras_json, '$.rightPowerPhase[0]'), json_extract_string(sample.extras_json, '$.right_power_phase[0]')) AS DOUBLE) AS right_power_phase_start_deg,
|
|
65
|
+
try_cast(coalesce(json_extract_string(sample.extras_json, '$.rightPowerPhase[1]'), json_extract_string(sample.extras_json, '$.right_power_phase[1]')) AS DOUBLE) AS right_power_phase_end_deg,
|
|
66
|
+
try_cast(coalesce(json_extract_string(sample.extras_json, '$.leftPowerPhasePeak[0]'), json_extract_string(sample.extras_json, '$.left_power_phase_peak[0]')) AS DOUBLE) AS left_power_phase_peak_start_deg,
|
|
67
|
+
try_cast(coalesce(json_extract_string(sample.extras_json, '$.leftPowerPhasePeak[1]'), json_extract_string(sample.extras_json, '$.left_power_phase_peak[1]')) AS DOUBLE) AS left_power_phase_peak_end_deg,
|
|
68
|
+
try_cast(coalesce(json_extract_string(sample.extras_json, '$.rightPowerPhasePeak[0]'), json_extract_string(sample.extras_json, '$.right_power_phase_peak[0]')) AS DOUBLE) AS right_power_phase_peak_start_deg,
|
|
69
|
+
try_cast(coalesce(json_extract_string(sample.extras_json, '$.rightPowerPhasePeak[1]'), json_extract_string(sample.extras_json, '$.right_power_phase_peak[1]')) AS DOUBLE) AS right_power_phase_peak_end_deg,
|
|
70
|
+
try_cast(coalesce(json_extract_string(sample.extras_json, '$.leftPlatformCenterOffset'), json_extract_string(sample.extras_json, '$.left_pco')) AS DOUBLE) AS left_platform_center_offset_mm,
|
|
71
|
+
try_cast(coalesce(json_extract_string(sample.extras_json, '$.rightPlatformCenterOffset'), json_extract_string(sample.extras_json, '$.right_pco')) AS DOUBLE) AS right_platform_center_offset_mm,
|
|
72
|
+
try_cast(sample.extras_json AS JSON) AS extras_json
|
|
73
|
+
FROM read_parquet(${sqlString(lakeGlob)}, union_by_name = true) AS sample
|
|
74
|
+
JOIN (
|
|
75
|
+
SELECT provider, activity_source_id, content_hash
|
|
76
|
+
FROM stream_manifest
|
|
77
|
+
QUALIFY row_number() OVER (PARTITION BY provider, activity_source_id ORDER BY content_hash DESC) = 1
|
|
78
|
+
) AS manifest USING (activity_source_id)
|
|
79
|
+
`;
|
|
80
|
+
}
|
|
81
|
+
return `SELECT
|
|
82
|
+
CAST(NULL AS VARCHAR) AS provider,
|
|
83
|
+
CAST(NULL AS VARCHAR) AS content_hash,
|
|
84
|
+
CAST(NULL AS VARCHAR) AS activity_source_id,
|
|
85
|
+
CAST(NULL AS TIMESTAMPTZ) AS timestamp_utc,
|
|
86
|
+
CAST(NULL AS DOUBLE) AS elapsed_s,
|
|
87
|
+
CAST(NULL AS DOUBLE) AS distance_m,
|
|
88
|
+
CAST(NULL AS DOUBLE) AS latitude,
|
|
89
|
+
CAST(NULL AS DOUBLE) AS longitude,
|
|
90
|
+
CAST(NULL AS DOUBLE) AS altitude_m,
|
|
91
|
+
CAST(NULL AS DOUBLE) AS heart_rate_bpm,
|
|
92
|
+
CAST(NULL AS DOUBLE) AS power_w,
|
|
93
|
+
CAST(NULL AS DOUBLE) AS cadence_rpm,
|
|
94
|
+
CAST(NULL AS DOUBLE) AS speed_mps,
|
|
95
|
+
CAST(NULL AS DOUBLE) AS temperature_c,
|
|
96
|
+
CAST(NULL AS DOUBLE) AS grade_pct,
|
|
97
|
+
CAST(NULL AS DOUBLE) AS left_right_balance_pct,
|
|
98
|
+
CAST(NULL AS DOUBLE) AS left_torque_effectiveness_pct,
|
|
99
|
+
CAST(NULL AS DOUBLE) AS right_torque_effectiveness_pct,
|
|
100
|
+
CAST(NULL AS DOUBLE) AS left_pedal_smoothness_pct,
|
|
101
|
+
CAST(NULL AS DOUBLE) AS right_pedal_smoothness_pct,
|
|
102
|
+
CAST(NULL AS DOUBLE) AS left_power_phase_start_deg,
|
|
103
|
+
CAST(NULL AS DOUBLE) AS left_power_phase_end_deg,
|
|
104
|
+
CAST(NULL AS DOUBLE) AS right_power_phase_start_deg,
|
|
105
|
+
CAST(NULL AS DOUBLE) AS right_power_phase_end_deg,
|
|
106
|
+
CAST(NULL AS DOUBLE) AS left_power_phase_peak_start_deg,
|
|
107
|
+
CAST(NULL AS DOUBLE) AS left_power_phase_peak_end_deg,
|
|
108
|
+
CAST(NULL AS DOUBLE) AS right_power_phase_peak_start_deg,
|
|
109
|
+
CAST(NULL AS DOUBLE) AS right_power_phase_peak_end_deg,
|
|
110
|
+
CAST(NULL AS DOUBLE) AS left_platform_center_offset_mm,
|
|
111
|
+
CAST(NULL AS DOUBLE) AS right_platform_center_offset_mm,
|
|
112
|
+
CAST(NULL AS JSON) AS extras_json
|
|
113
|
+
WHERE FALSE`;
|
|
114
|
+
}
|
|
115
|
+
async status() {
|
|
116
|
+
// @duckdb/node-api shares native statement state on a connection. These
|
|
117
|
+
// small status reads must remain sequential; concurrent calls can hang a
|
|
118
|
+
// read-only dashboard request before it writes its response.
|
|
119
|
+
const runs = await this.rows(`SELECT run_id, provider, cast(from_date AS VARCHAR) AS from_date, cast(started_at AS VARCHAR) AS started_at, cast(completed_at AS VARCHAR) AS completed_at, status, error_count FROM sync_runs ORDER BY started_at DESC LIMIT 20`);
|
|
120
|
+
const counts = await this.rows(`SELECT
|
|
121
|
+
(SELECT count(*)::INTEGER FROM activities) AS activities,
|
|
122
|
+
(SELECT count(*)::INTEGER FROM activity_sources) AS activity_sources,
|
|
123
|
+
(SELECT count(*)::INTEGER FROM daily_metrics) AS daily_metrics,
|
|
124
|
+
(SELECT count(*)::INTEGER FROM nutrition_items) AS nutrition_items,
|
|
125
|
+
(SELECT count(*)::INTEGER FROM source_entities) AS source_entities,
|
|
126
|
+
(SELECT count(*)::INTEGER FROM retrieval_documents) AS retrieval_documents`);
|
|
127
|
+
const errors = await this.rows(`SELECT count(*)::INTEGER AS unresolved FROM normalization_errors WHERE resolved_at IS NULL`);
|
|
128
|
+
const streamCoverage = await this.rows(`SELECT count(*)::INTEGER AS streams, coalesce(sum(row_count), 0)::BIGINT AS sample_rows, min(start_at) AS starts_at, max(end_at) AS ends_at FROM stream_manifest`);
|
|
129
|
+
const indexState = await this.rows(`SELECT status, mode, source_watermark, cast(built_at AS VARCHAR) AS built_at FROM retrieval_index_state WHERE index_name = 'context'`);
|
|
130
|
+
const cursors = await this.rows(`SELECT provider, cursor_name, cast(covered_through_date AS VARCHAR) AS covered_through_date, cast(latest_source_date AS VARCHAR) AS latest_source_date, lookback_days, cast(last_completed_at AS VARCHAR) AS last_completed_at, status FROM sync_cursors ORDER BY provider, cursor_name`);
|
|
131
|
+
return {
|
|
132
|
+
syncRuns: runs,
|
|
133
|
+
entityCounts: counts[0] ?? {},
|
|
134
|
+
unresolvedExtractionErrors: errors[0]?.unresolved ?? 0,
|
|
135
|
+
streams: streamCoverage[0] ?? { streams: 0, sample_rows: 0 },
|
|
136
|
+
retrievalIndex: indexState[0] ?? { status: 'stale', mode: 'keyword' },
|
|
137
|
+
incrementalCursors: cursors,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
async coverage() {
|
|
141
|
+
const rows = await this.rows(`
|
|
142
|
+
SELECT 'activities' AS dataset, min(cast(started_at_utc AS DATE)) AS start_date, max(cast(started_at_utc AS DATE)) AS end_date, count(*)::INTEGER AS row_count FROM activities
|
|
143
|
+
UNION ALL SELECT 'daily_metrics', min(metric_date), max(metric_date), count(*)::INTEGER FROM daily_metrics
|
|
144
|
+
UNION ALL SELECT 'training_metric_observations', min(cast(observed_at AS DATE)), max(cast(observed_at AS DATE)), count(*)::INTEGER FROM training_metric_observations
|
|
145
|
+
UNION ALL SELECT 'nutrition_days', min(nutrition_date), max(nutrition_date), count(*)::INTEGER FROM nutrition_days
|
|
146
|
+
UNION ALL SELECT 'source_entities', min(occurred_on), max(occurred_on), count(*)::INTEGER FROM source_entities
|
|
147
|
+
`);
|
|
148
|
+
return { coverage: rows, providers: await this.rows(`SELECT provider, count(*)::INTEGER AS activity_sources FROM activity_sources GROUP BY provider ORDER BY provider`) };
|
|
149
|
+
}
|
|
150
|
+
async activity(activityId) {
|
|
151
|
+
const activity = await this.rows(`SELECT * FROM activities WHERE activity_id = $activityId`, { activityId });
|
|
152
|
+
if (!activity[0])
|
|
153
|
+
return null;
|
|
154
|
+
const sources = await this.rows(`SELECT * FROM activity_sources WHERE activity_id = $activityId ORDER BY CASE provider WHEN 'garmin' THEN 0 WHEN 'intervals' THEN 1 ELSE 2 END, provider`, { activityId });
|
|
155
|
+
const summaries = await this.rows(`SELECT * FROM activity_summary_facts WHERE activity_id = $activityId ORDER BY CASE provider WHEN 'garmin' THEN 0 WHEN 'intervals' THEN 1 ELSE 2 END, provider`, { activityId });
|
|
156
|
+
const intervals = await this.rows(`SELECT * FROM activity_interval_facts WHERE activity_id = $activityId ORDER BY CASE provider WHEN 'garmin' THEN 0 WHEN 'intervals' THEN 1 ELSE 2 END, provider, start_s`, { activityId });
|
|
157
|
+
return { activity: activity[0], sources, summaries, intervals };
|
|
158
|
+
}
|
|
159
|
+
async summary(startDate, endDate) {
|
|
160
|
+
const training = await this.rows(`SELECT cast(started_at_utc AS DATE) AS date, count(*)::INTEGER AS activities, sum(distance_m) AS distance_m, sum(moving_s) AS moving_s, sum(training_load) AS training_load FROM canonical_activity_training WHERE cast(started_at_utc AS DATE) BETWEEN cast($startDate AS DATE) AND cast($endDate AS DATE) GROUP BY 1 ORDER BY 1`, { startDate, endDate });
|
|
161
|
+
const health = await this.rows(`SELECT * FROM daily_health WHERE metric_date BETWEEN cast($startDate AS DATE) AND cast($endDate AS DATE) ORDER BY metric_date, provider`, { startDate, endDate });
|
|
162
|
+
const nutrition = await this.rows(`SELECT * FROM nutrition_days WHERE nutrition_date BETWEEN cast($startDate AS DATE) AND cast($endDate AS DATE) ORDER BY nutrition_date, provider`, { startDate, endDate });
|
|
163
|
+
return { training, health, nutrition };
|
|
164
|
+
}
|
|
165
|
+
catalog() {
|
|
166
|
+
return Object.values(DATASET_CATALOG);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Converts DuckDB's Date/BigInt values into MCP-safe JSON values. */
|
|
170
|
+
export function jsonSafe(value) {
|
|
171
|
+
if (typeof value === 'bigint')
|
|
172
|
+
return String(value);
|
|
173
|
+
if (value instanceof Date)
|
|
174
|
+
return value.toISOString();
|
|
175
|
+
if (Array.isArray(value))
|
|
176
|
+
return value.map((item) => jsonSafe(item));
|
|
177
|
+
if (value && typeof value === 'object') {
|
|
178
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, jsonSafe(item)]));
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=repository.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ReadOnlyRepository } from './repository.js';
|
|
2
|
+
export declare function queryReadOnlyData(repository: ReadOnlyRepository, request: {
|
|
3
|
+
sql: string;
|
|
4
|
+
values?: Record<string, string | number | boolean | null>;
|
|
5
|
+
cursor?: string;
|
|
6
|
+
pageSize?: number;
|
|
7
|
+
}): Promise<Record<string, unknown>>;
|