catence 0.2.0-beta.21 → 0.2.0-beta.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/query/activity-discovery.d.ts +2 -0
- package/dist/core/query/activity-discovery.js +1 -1
- package/dist/core/query/analytics.d.ts +10 -0
- package/dist/core/query/analytics.js +494 -0
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +8 -4
- package/dist/core/query/events.d.ts +34 -0
- package/dist/core/query/events.js +121 -0
- package/dist/core/query/fitness.d.ts +4 -0
- package/dist/core/query/fitness.js +60 -0
- package/dist/core/query/swimming.js +2 -2
- package/dist/elt/ingestion/providers/registry.js +1 -0
- package/dist/elt/normalization/normalizers.js +96 -0
- package/dist/elt/storage/migrations.js +121 -1
- package/dist/interfaces/cli/commands.js +37 -1
- package/dist/interfaces/mcp/main.js +33 -1
- package/dist/interfaces/mcp/server.js +101 -34
- package/dist/runtime/index.d.ts +3 -1
- package/dist/runtime/index.js +3 -1
- package/package.json +1 -1
- package/python/catence/providers/garmin/registry.py +1 -0
- package/python/catence/providers/garmin/worker.py +45 -3
|
@@ -5,8 +5,8 @@ export const DATASET_CATALOG = {
|
|
|
5
5
|
columns: [column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('started_at_local', 'VARCHAR'), column('timezone', 'VARCHAR'), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('name', 'VARCHAR', { filterable: true }), column('link_state', 'VARCHAR', { filterable: true, groupable: true })],
|
|
6
6
|
},
|
|
7
7
|
canonical_activities: {
|
|
8
|
-
name: 'canonical_activities', relation: 'canonical_activity_training', description: 'One training interpretation per linked activity; Garmin is canonical when present, while Intervals analysis values remain separately provenance-tagged.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_id', 'activity_source_id', 'provider'], permittedGroupings: ['provider', 'sport'],
|
|
9
|
-
columns: [column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_source_id', 'VARCHAR', { filterable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('name', 'VARCHAR'), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('moving_s', 'DOUBLE', { unit: 's', metric: true }), column('elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('elevation_gain_m', 'DOUBLE', { unit: 'm', metric: true }), column('calories', 'DOUBLE', { unit: 'kcal', metric: true }), column('avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('max_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('avg_power', 'DOUBLE', { unit: 'W', metric: true }), column('weighted_power', 'DOUBLE', { unit: 'W', metric: true }), column('training_load', 'DOUBLE', { metric: true }), column('rpe', 'DOUBLE', { metric: true }), column('feel', 'DOUBLE', { metric: true })],
|
|
8
|
+
name: 'canonical_activities', relation: 'canonical_activity_training', description: 'One training interpretation per linked activity; Garmin is canonical when present, while Intervals analysis values remain separately provenance-tagged. A NULL avg_power here does not mean no power was recorded: running/cycling power lives in power_best_facts (Garmin FIT-derived). Use power_coverage_report or read_series on activity_samples rather than inferring absence. derived_avg_power_w is the FIT-derived mean per activity (avg of power_best_facts best_power_w) when summary avg_power is sparse; prefer coalesce(avg_power, derived_avg_power_w) for coverage-aware aggregates. NULL avg_power here does not mean no power; running/cycling power is in power_best_facts (FIT-derived). Use power_coverage_report / read_series.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_id', 'activity_source_id', 'provider'], permittedGroupings: ['provider', 'sport'],
|
|
9
|
+
columns: [column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_source_id', 'VARCHAR', { filterable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('name', 'VARCHAR'), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('moving_s', 'DOUBLE', { unit: 's', metric: true }), column('elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('elevation_gain_m', 'DOUBLE', { unit: 'm', metric: true }), column('calories', 'DOUBLE', { unit: 'kcal', metric: true }), column('avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('max_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('avg_power', 'DOUBLE', { unit: 'W', metric: true }), column('derived_avg_power_w', 'DOUBLE', { unit: 'W', metric: true, description: 'FIT-derived mean power per activity (avg of power_best_facts best_power_w); complement for sparse avg_power.' }), column('weighted_power', 'DOUBLE', { unit: 'W', metric: true }), column('training_load', 'DOUBLE', { metric: true }), column('rpe', 'DOUBLE', { metric: true }), column('feel', 'DOUBLE', { metric: true })],
|
|
10
10
|
},
|
|
11
11
|
canonical_activity_facts: {
|
|
12
12
|
name: 'canonical_activity_facts', relation: 'canonical_activity_facts', description: 'Field-level canonical activity values. Garmin is preferred when available; the original Garmin and Intervals values, selected source, disagreement, and quality flags remain visible.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_id', 'garmin_activity_source_id', 'intervals_activity_source_id', 'distance_source', 'quality_flags'], permittedGroupings: ['sport', 'link_state', 'distance_source'],
|
|
@@ -17,7 +17,7 @@ export const DATASET_CATALOG = {
|
|
|
17
17
|
columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('remote_activity_id', 'VARCHAR', { filterable: true }), column('external_id', 'VARCHAR', { filterable: true }), column('raw_object_hash', 'VARCHAR')],
|
|
18
18
|
},
|
|
19
19
|
activity_summaries: {
|
|
20
|
-
name: 'activity_summaries', relation: 'activity_summary_facts', description: 'Provider activity summary metrics with activity and timestamp provenance.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_source_id', 'activity_id', 'provider', 'raw_object_hash'], permittedGroupings: ['provider', 'sport'],
|
|
20
|
+
name: 'activity_summaries', relation: 'activity_summary_facts', description: 'Provider activity summary metrics with activity and timestamp provenance. A NULL avg_power does not mean no power; FIT-derived power is in power_best_facts. Prefer power_coverage_report or read_series over inferring absence. NULL avg_power here does not mean no power; running/cycling power is in power_best_facts (FIT-derived). Use power_coverage_report / read_series.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_source_id', 'activity_id', 'provider', 'raw_object_hash'], permittedGroupings: ['provider', 'sport'],
|
|
21
21
|
columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('name', 'VARCHAR', { filterable: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('moving_s', 'DOUBLE', { unit: 's', metric: true }), column('elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('elevation_gain_m', 'DOUBLE', { unit: 'm', metric: true }), column('calories', 'DOUBLE', { unit: 'kcal', metric: true }), column('avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('max_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('avg_power', 'DOUBLE', { unit: 'W', metric: true }), column('weighted_power', 'DOUBLE', { unit: 'W', metric: true }), column('avg_cadence', 'DOUBLE', { unit: 'rpm', metric: true }), column('training_load', 'DOUBLE', { metric: true }), column('rpe', 'DOUBLE', { metric: true }), column('feel', 'DOUBLE', { metric: true })],
|
|
22
22
|
},
|
|
23
23
|
activity_intervals: {
|
|
@@ -25,7 +25,7 @@ export const DATASET_CATALOG = {
|
|
|
25
25
|
columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('interval_key', 'VARCHAR', { filterable: true }), column('label', 'VARCHAR', { filterable: true, groupable: true }), column('source_type', 'VARCHAR', { filterable: true, groupable: true }), column('start_s', 'DOUBLE', { unit: 's', metric: true }), column('end_s', 'DOUBLE', { unit: 's', metric: true }), column('duration_s', 'DOUBLE', { unit: 's', metric: true }), column('moving_s', 'DOUBLE', { unit: 's', metric: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('avg_power', 'DOUBLE', { unit: 'W', metric: true }), column('avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('avg_pace', 'DOUBLE', { metric: true }), column('intensity', 'DOUBLE', { metric: true })],
|
|
26
26
|
},
|
|
27
27
|
swim_lengths: {
|
|
28
|
-
name: 'swim_lengths', relation: 'swim_length_facts', description: 'Explicit provider-supplied swim-length facts. An empty result means lengths were not supplied; Catence does not infer them from activity samples.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_source_id', 'source', 'confidence', 'raw_object_hash'], permittedGroupings: ['provider', 'sport', 'source', 'confidence', 'is_rest'],
|
|
28
|
+
name: 'swim_lengths', relation: 'swim_length_facts', description: 'Explicit provider-supplied swim-length facts. An empty result means lengths were not supplied; Catence does not infer them from activity samples. Per-length swolf may be null (not supplied by all providers); derive as duration_s + stroke_count. Per-length distance is typically null in Garmin FIT; use pool_length_m to compute pace (duration_s / pool_length_m * 100 for s/100m).', dateColumn: 'started_at_utc', provenanceColumns: ['activity_source_id', 'source', 'confidence', 'raw_object_hash'], permittedGroupings: ['provider', 'sport', 'source', 'confidence', 'is_rest'],
|
|
29
29
|
columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('source', 'VARCHAR', { filterable: true, groupable: true }), column('confidence', 'VARCHAR', { filterable: true, groupable: true }), column('length_index', 'INTEGER', { filterable: true, groupable: true }), column('lap_index', 'INTEGER', { filterable: true, groupable: true }), column('pool_length_m', 'DOUBLE', { unit: 'm', metric: true }), column('start_time', 'TIMESTAMPTZ', { filterable: true }), column('duration_s', 'DOUBLE', { unit: 's', metric: true }), column('active_duration_s', 'DOUBLE', { unit: 's', metric: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('stroke_count', 'DOUBLE', { unit: 'count', metric: true }), column('stroke_rate', 'DOUBLE', { unit: 'strokes/min', metric: true }), column('swolf', 'DOUBLE', { metric: true }), column('avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('max_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('is_rest', 'BOOLEAN', { filterable: true, groupable: true }), column('raw_object_hash', 'VARCHAR')],
|
|
30
30
|
},
|
|
31
31
|
swim_sets: {
|
|
@@ -60,6 +60,10 @@ export const DATASET_CATALOG = {
|
|
|
60
60
|
name: 'power_bests', relation: 'power_best_facts', description: 'Power-duration bests derived from Garmin FIT streams; they are labelled derived while retaining the Garmin activity provenance.', dateColumn: 'started_at_utc', provenanceColumns: ['provider', 'activity_source_id', 'source_type', 'raw_object_hash'], permittedGroupings: ['provider', 'sport', 'duration_s', 'source_type'],
|
|
61
61
|
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('duration_s', 'INTEGER', { filterable: true, groupable: true }), column('best_power_w', 'DOUBLE', { unit: 'W', metric: true }), column('source_type', 'VARCHAR', { filterable: true, groupable: true }), column('raw_object_hash', 'VARCHAR')],
|
|
62
62
|
},
|
|
63
|
+
activity_decoupling: {
|
|
64
|
+
name: 'activity_decoupling', relation: 'activity_decoupling_facts', description: 'Persisted per-activity aerobic decoupling (Pa:Hr, pct) and grade-adjusted pace (s/km) with provenance. source_type provider means the provider payload supplied the value (authoritative); derived values are tagged locally. Values never overwrite provider output. Not a physiological performance model.', dateColumn: 'started_at_utc', provenanceColumns: ['provider', 'activity_source_id', 'raw_object_hash'], permittedGroupings: ['provider', 'sport', 'metric', 'source_type'],
|
|
65
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('metric', 'VARCHAR', { filterable: true, groupable: true }), column('value_number', 'DOUBLE', { metric: true }), column('unit', 'VARCHAR', { filterable: true, groupable: true }), column('source_type', 'VARCHAR', { filterable: true, groupable: true }), column('raw_object_hash', 'VARCHAR')],
|
|
66
|
+
},
|
|
63
67
|
nutrition_days: {
|
|
64
68
|
name: 'nutrition_days', relation: 'nutrition_days', description: 'Daily nutrition totals, never a replacement for the item-level food log.', dateColumn: 'nutrition_date', provenanceColumns: ['provider', 'raw_object_hash'], permittedGroupings: ['provider'],
|
|
65
69
|
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('nutrition_date', 'DATE', { filterable: true, groupable: true }), column('energy_kcal', 'DOUBLE', { unit: 'kcal', metric: true }), column('carbohydrates_g', 'DOUBLE', { unit: 'g', metric: true }), column('protein_g', 'DOUBLE', { unit: 'g', metric: true }), column('fat_g', 'DOUBLE', { unit: 'g', metric: true }), column('hydration_ml', 'DOUBLE', { unit: 'ml', metric: true }), column('raw_object_hash', 'VARCHAR')],
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ReadOnlyRepository } from './repository.js';
|
|
2
|
+
/** A courseId resolved from an event (or activity) payload, or null when absent. */
|
|
3
|
+
export type ResolvedCourseId = string | null;
|
|
4
|
+
export interface ResolveEventCourseResult {
|
|
5
|
+
eventId: string;
|
|
6
|
+
provider: string | null;
|
|
7
|
+
courseId: ResolvedCourseId;
|
|
8
|
+
courseSynced: boolean;
|
|
9
|
+
geometrySampleCount: number;
|
|
10
|
+
caveats: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface DiffCourseProfilesResult {
|
|
13
|
+
eventCourseId: ResolvedCourseId;
|
|
14
|
+
pastActivityCourseId: ResolvedCourseId;
|
|
15
|
+
sameCourse: boolean | null;
|
|
16
|
+
differentCourse: boolean | null;
|
|
17
|
+
caveats: string[];
|
|
18
|
+
}
|
|
19
|
+
/** Pull a courseId from a Garmin/Intervals event/activity payload, tolerating the
|
|
20
|
+
* common key spellings and a nested `course.id` shape. Returns null when the
|
|
21
|
+
* payload carries no course reference rather than guessing. */
|
|
22
|
+
export declare function extractCourseId(payload: unknown): ResolvedCourseId;
|
|
23
|
+
export declare class EventsService {
|
|
24
|
+
private readonly repository;
|
|
25
|
+
constructor(repository: ReadOnlyRepository);
|
|
26
|
+
/** Resolve an event's referenced Garmin courseId and report whether its
|
|
27
|
+
* geometry has been synced. When geometry is absent, the result carries an
|
|
28
|
+
* explicit caveat so the agent never silently reuses a prior course profile. */
|
|
29
|
+
resolveEventCourse(eventId: string): Promise<ResolveEventCourseResult>;
|
|
30
|
+
/** Compare an event's course against a past activity's course. A different
|
|
31
|
+
* courseId means the prior profile does not apply; an absent reference is
|
|
32
|
+
* reported as unknown rather than assumed equal. */
|
|
33
|
+
diffCourseProfiles(eventCourseId: ResolvedCourseId, pastActivityId?: string): Promise<DiffCourseProfilesResult>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
const COURSE_ID_KEYS = ['courseId', 'course_id', 'courseID'];
|
|
2
|
+
/** Pull a courseId from a Garmin/Intervals event/activity payload, tolerating the
|
|
3
|
+
* common key spellings and a nested `course.id` shape. Returns null when the
|
|
4
|
+
* payload carries no course reference rather than guessing. */
|
|
5
|
+
export function extractCourseId(payload) {
|
|
6
|
+
let normalized = payload;
|
|
7
|
+
if (typeof payload === 'string') {
|
|
8
|
+
try {
|
|
9
|
+
normalized = JSON.parse(payload);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
normalized = {};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const record = (typeof normalized === 'object' && normalized !== null && !Array.isArray(normalized)
|
|
16
|
+
? normalized
|
|
17
|
+
: {});
|
|
18
|
+
for (const key of COURSE_ID_KEYS) {
|
|
19
|
+
const value = record[key];
|
|
20
|
+
if (typeof value === 'string' && value.length > 0)
|
|
21
|
+
return value;
|
|
22
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
23
|
+
return String(value);
|
|
24
|
+
}
|
|
25
|
+
const nested = record.course;
|
|
26
|
+
if (nested !== null && typeof nested === 'object' && !Array.isArray(nested)) {
|
|
27
|
+
const nestedId = nested.id;
|
|
28
|
+
if (typeof nestedId === 'string' && nestedId.length > 0)
|
|
29
|
+
return nestedId;
|
|
30
|
+
if (typeof nestedId === 'number' && Number.isFinite(nestedId))
|
|
31
|
+
return String(nestedId);
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
export class EventsService {
|
|
36
|
+
repository;
|
|
37
|
+
constructor(repository) {
|
|
38
|
+
this.repository = repository;
|
|
39
|
+
}
|
|
40
|
+
/** Resolve an event's referenced Garmin courseId and report whether its
|
|
41
|
+
* geometry has been synced. When geometry is absent, the result carries an
|
|
42
|
+
* explicit caveat so the agent never silently reuses a prior course profile. */
|
|
43
|
+
async resolveEventCourse(eventId) {
|
|
44
|
+
const caveats = [];
|
|
45
|
+
const events = await this.repository.rows(`SELECT provider, payload_json FROM source_entities
|
|
46
|
+
WHERE entity_type = 'event' AND remote_id = $eventId
|
|
47
|
+
ORDER BY provider = 'garmin' DESC, remote_id LIMIT 1`, { eventId });
|
|
48
|
+
if (events.length === 0) {
|
|
49
|
+
return {
|
|
50
|
+
eventId,
|
|
51
|
+
provider: null,
|
|
52
|
+
courseId: null,
|
|
53
|
+
courseSynced: false,
|
|
54
|
+
geometrySampleCount: 0,
|
|
55
|
+
caveats: [`No event source entity found for eventId ${eventId}.`],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const event = events[0];
|
|
59
|
+
const courseId = extractCourseId(event.payload_json);
|
|
60
|
+
if (courseId === null) {
|
|
61
|
+
return {
|
|
62
|
+
eventId,
|
|
63
|
+
provider: event.provider,
|
|
64
|
+
courseId: null,
|
|
65
|
+
courseSynced: false,
|
|
66
|
+
geometrySampleCount: 0,
|
|
67
|
+
caveats: [`Event ${eventId} does not reference a courseId; no course profile to resolve.`],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const synced = await this.repository.rows(`SELECT remote_id FROM source_entities
|
|
71
|
+
WHERE entity_type = 'course' AND remote_id = $courseId LIMIT 1`, { courseId });
|
|
72
|
+
const geometry = await this.repository.rows(`SELECT count(*) AS sample_count FROM course_geometry WHERE course_id = $courseId`, { courseId });
|
|
73
|
+
const geometrySampleCount = geometry.length > 0 ? Number(geometry[0].sample_count) : 0;
|
|
74
|
+
const courseSynced = synced.length > 0 && geometrySampleCount > 0;
|
|
75
|
+
if (!courseSynced) {
|
|
76
|
+
caveats.push(`Course geometry for courseId ${courseId} is not yet synced; this year's gradient is unverified until a recon run maps to that course. Do not reuse a prior course profile.`);
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
eventId,
|
|
80
|
+
provider: event.provider,
|
|
81
|
+
courseId,
|
|
82
|
+
courseSynced,
|
|
83
|
+
geometrySampleCount,
|
|
84
|
+
caveats,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Compare an event's course against a past activity's course. A different
|
|
88
|
+
* courseId means the prior profile does not apply; an absent reference is
|
|
89
|
+
* reported as unknown rather than assumed equal. */
|
|
90
|
+
async diffCourseProfiles(eventCourseId, pastActivityId) {
|
|
91
|
+
const caveats = [];
|
|
92
|
+
let pastActivityCourseId = null;
|
|
93
|
+
if (pastActivityId) {
|
|
94
|
+
const activities = await this.repository.rows(`SELECT payload_json FROM source_entities
|
|
95
|
+
WHERE entity_type = 'activity' AND remote_id = $activityId LIMIT 1`, { activityId: pastActivityId });
|
|
96
|
+
if (activities.length > 0) {
|
|
97
|
+
pastActivityCourseId = extractCourseId(activities[0].payload_json);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
caveats.push(`No activity source entity found for pastActivityId ${pastActivityId}; could not resolve its course.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
let sameCourse = null;
|
|
104
|
+
let differentCourse = null;
|
|
105
|
+
if (eventCourseId !== null && pastActivityCourseId !== null) {
|
|
106
|
+
sameCourse = eventCourseId === pastActivityCourseId;
|
|
107
|
+
differentCourse = !sameCourse;
|
|
108
|
+
if (differentCourse) {
|
|
109
|
+
caveats.push(`Event courseId ${eventCourseId} differs from past activity courseId ${pastActivityCourseId}; do not reuse the prior course profile.`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else if (eventCourseId === null) {
|
|
113
|
+
caveats.push('Event courseId is unknown; a prior course profile cannot be validated against it.');
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
caveats.push('Past activity courseId is unknown; a prior course profile cannot be confirmed or refuted.');
|
|
117
|
+
}
|
|
118
|
+
return { eventCourseId, pastActivityCourseId, sameCourse, differentCourse, caveats };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=events.js.map
|
|
@@ -26,6 +26,9 @@ export type LatestCyclingActivitiesRequest = FitnessDateRange & {
|
|
|
26
26
|
includeMultisport?: boolean;
|
|
27
27
|
limit?: number;
|
|
28
28
|
};
|
|
29
|
+
export type ReadinessBaselineRequest = FitnessDateRange & {
|
|
30
|
+
sport?: string;
|
|
31
|
+
};
|
|
29
32
|
/** Read-only, source-aware fitness projections built on normalized facts. */
|
|
30
33
|
export declare class FitnessService {
|
|
31
34
|
private readonly repository;
|
|
@@ -36,5 +39,6 @@ export declare class FitnessService {
|
|
|
36
39
|
powerCoverageReport(request?: PowerCoverageReportRequest): Promise<Record<string, unknown>>;
|
|
37
40
|
latestCyclingActivities(request?: LatestCyclingActivitiesRequest): Promise<Record<string, unknown>>;
|
|
38
41
|
cyclingProgressReport(request?: FitnessDateRange): Promise<Record<string, unknown>>;
|
|
42
|
+
readinessBaseline(request?: ReadinessBaselineRequest): Promise<Record<string, unknown>>;
|
|
39
43
|
}
|
|
40
44
|
export {};
|
|
@@ -293,5 +293,65 @@ export class FitnessService {
|
|
|
293
293
|
],
|
|
294
294
|
};
|
|
295
295
|
}
|
|
296
|
+
async readinessBaseline(request = {}) {
|
|
297
|
+
const sport = request.sport ?? 'running';
|
|
298
|
+
const isRunning = /run/i.test(sport);
|
|
299
|
+
const values = { sport };
|
|
300
|
+
const metricClauses = [
|
|
301
|
+
`metric_name IN ('running_lactate_threshold_pace_s_per_km','running_lactate_threshold_power_w','running_lactate_threshold_hr_bpm','running_lactate_threshold_power_w_kg','race_prediction_time5_k','race_prediction_time10_k','race_prediction_time_half_marathon','race_prediction_time_marathon','training_status_fitness_trend','endurance_score_overall_score')`,
|
|
302
|
+
`(lower(sport) = lower($sport) OR lower(sport) = 'generic')`,
|
|
303
|
+
...addDateRange('observed_at', request, values),
|
|
304
|
+
];
|
|
305
|
+
const observations = await this.repository.rows(`
|
|
306
|
+
SELECT observation_id, cast(observed_at AS VARCHAR) AS observed_at, value_number, value_text, unit, sport,
|
|
307
|
+
source_type, source_remote_id, activity_source_id, raw_object_hash, metric_name
|
|
308
|
+
FROM training_metric_observations
|
|
309
|
+
WHERE ${metricClauses.join(' AND ')}
|
|
310
|
+
ORDER BY observed_at DESC, observation_id DESC
|
|
311
|
+
`, values);
|
|
312
|
+
const [vo2max, powerCurve, powerCoverage] = await Promise.all([
|
|
313
|
+
this.vo2MaxHistory({ ...request, sport }),
|
|
314
|
+
this.powerCurveTrend({ ...request, sportFamily: isRunning ? 'running' : 'cycling' }),
|
|
315
|
+
this.powerCoverageReport({ ...request, sportFamily: isRunning ? 'running' : 'cycling' }),
|
|
316
|
+
]);
|
|
317
|
+
const byMetric = new Map();
|
|
318
|
+
for (const row of observations) {
|
|
319
|
+
const arr = byMetric.get(row.metric_name) ?? [];
|
|
320
|
+
arr.push(row);
|
|
321
|
+
byMetric.set(row.metric_name, arr);
|
|
322
|
+
}
|
|
323
|
+
const latest = (name) => byMetric.get(name)?.[0] ?? null;
|
|
324
|
+
const series = (name) => byMetric.get(name) ?? [];
|
|
325
|
+
return {
|
|
326
|
+
data: {
|
|
327
|
+
sport,
|
|
328
|
+
lactateThreshold: {
|
|
329
|
+
pace_s_per_km: latest('running_lactate_threshold_pace_s_per_km'),
|
|
330
|
+
power_w: latest('running_lactate_threshold_power_w'),
|
|
331
|
+
hr_bpm: latest('running_lactate_threshold_hr_bpm'),
|
|
332
|
+
power_w_kg: latest('running_lactate_threshold_power_w_kg'),
|
|
333
|
+
powerSeries: series('running_lactate_threshold_power_w'),
|
|
334
|
+
},
|
|
335
|
+
vo2max,
|
|
336
|
+
racePrediction: {
|
|
337
|
+
time5_k: latest('race_prediction_time5_k'),
|
|
338
|
+
time10_k: latest('race_prediction_time10_k'),
|
|
339
|
+
timeHalfMarathon: latest('race_prediction_time_half_marathon'),
|
|
340
|
+
timeMarathon: latest('race_prediction_time_marathon'),
|
|
341
|
+
},
|
|
342
|
+
fitnessTrend: latest('training_status_fitness_trend'),
|
|
343
|
+
enduranceScore: latest('endurance_score_overall_score'),
|
|
344
|
+
powerCurve,
|
|
345
|
+
powerCoverage,
|
|
346
|
+
},
|
|
347
|
+
provenance: { dataset: 'training_metric_observations', powerDataset: 'power_best_facts' },
|
|
348
|
+
query: request,
|
|
349
|
+
caveats: [
|
|
350
|
+
'Readiness baseline is descriptive, not a physiological performance model; it combines lactate threshold, VO₂max, race prediction, fitness trend, and FIT-derived power.',
|
|
351
|
+
'Running power uses Garmin FIT-derived power bests (power_best_facts), not sparse activity-summary average power; a NULL summary avg_power does not mean no power.',
|
|
352
|
+
'Course/elevation profile for a specific race is not included here; call resolve_event_course(eventId) before reusing any prior course profile.',
|
|
353
|
+
],
|
|
354
|
+
};
|
|
355
|
+
}
|
|
296
356
|
}
|
|
297
357
|
//# sourceMappingURL=fitness.js.map
|
|
@@ -86,7 +86,7 @@ export class SwimmingService {
|
|
|
86
86
|
availability: {
|
|
87
87
|
explicitLengthFacts: lengths.length > 0,
|
|
88
88
|
setFacts: sets.length > 0,
|
|
89
|
-
perLengthPaceAvailable: lengths.some((length) => Number(length.duration_s) > 0
|
|
89
|
+
perLengthPaceAvailable: lengths.some((length) => Number(length.duration_s) > 0),
|
|
90
90
|
},
|
|
91
91
|
};
|
|
92
92
|
}));
|
|
@@ -123,7 +123,7 @@ export class SwimmingService {
|
|
|
123
123
|
FROM activity_quality_flags GROUP BY activity_source_id
|
|
124
124
|
), lengths AS (
|
|
125
125
|
SELECT activity_source_id, count(*) AS length_fact_count,
|
|
126
|
-
count(*) FILTER (WHERE duration_s > 0
|
|
126
|
+
count(*) FILTER (WHERE duration_s > 0) AS pace_length_fact_count
|
|
127
127
|
FROM swim_lengths GROUP BY activity_source_id
|
|
128
128
|
), sets AS (
|
|
129
129
|
SELECT activity_source_id, count(*) AS set_fact_count FROM swim_sets GROUP BY activity_source_id
|
|
@@ -60,6 +60,7 @@ export const garminReadRegistry = registry('garmin', [
|
|
|
60
60
|
['activity_original', 'activity', 'activity_file'], ['activity_tcx', 'activity', 'activity_file'], ['activity_gpx', 'activity', 'activity_file'], ['activity_kml', 'activity', 'activity_file'],
|
|
61
61
|
['activity_csv', 'activity', 'activity_file'], ['workouts', 'collection', 'workout'], ['scheduled_workouts', 'date_range', 'scheduled_workout'],
|
|
62
62
|
['events', 'collection', 'event'],
|
|
63
|
+
['course', 'child', 'course'],
|
|
63
64
|
['training_plans', 'collection', 'training_plan'], ['goals', 'collection', 'goal'], ['personal_records', 'singleton', 'personal_record'],
|
|
64
65
|
['earned_badges', 'collection', 'badge'], ['available_badges', 'collection', 'badge'], ['challenges', 'collection', 'challenge'],
|
|
65
66
|
['gear', 'collection', 'gear'], ['golf_summary', 'collection', 'golf_scorecard'], ['golf_user_stats', 'singleton', 'golf_user_stat'],
|
|
@@ -253,6 +253,7 @@ export async function importSourceEntity(database, entity) {
|
|
|
253
253
|
}
|
|
254
254
|
if (entity.entityType === 'activity') {
|
|
255
255
|
await importActivity(database, entity.provider, entity.remoteId, payload, rawHash);
|
|
256
|
+
await importActivityDecoupling(database, entity.provider, entity.remoteId, payload, rawHash);
|
|
256
257
|
if (entity.provider === 'intervals')
|
|
257
258
|
await importIntervalsAutoSwimSets(database, entity.remoteId, payload, rawHash);
|
|
258
259
|
if (entity.provider === 'garmin') {
|
|
@@ -290,6 +291,8 @@ export async function importSourceEntity(database, entity) {
|
|
|
290
291
|
await importHealthSession(database, entity.remoteId, entity.occurredOn, 'daily_event', payload, rawHash);
|
|
291
292
|
if (entity.provider === 'garmin' && entity.entityType === 'activity_power_best')
|
|
292
293
|
await importActivityPowerBest(database, entity.parentRemoteId, payload, rawHash);
|
|
294
|
+
if (entity.provider === 'garmin' && entity.entityType === 'course')
|
|
295
|
+
await importGarminCourse(database, entity.remoteId, payload, rawHash);
|
|
293
296
|
}
|
|
294
297
|
async function importActivity(database, provider, remoteId, payload, rawHash) {
|
|
295
298
|
const fields = activityFields(provider, payload);
|
|
@@ -519,6 +522,99 @@ async function importGarminNumericSeries(database, entityType, remoteId, occurre
|
|
|
519
522
|
});
|
|
520
523
|
}
|
|
521
524
|
}
|
|
525
|
+
const DECOUPLING_METRICS = [
|
|
526
|
+
{ metric: 'aerobic_decoupling_pct', unit: 'pct', keys: ['decoupling', 'aerobicDecoupling', 'icu_decoupling', 'paHrDecoupling', 'pwHrDecoupling', 'aerobic_decoupling'] },
|
|
527
|
+
{ metric: 'grade_adjusted_pace_s_per_km', unit: 's_per_km', keys: ['gap', 'gradeAdjustedPace', 'icu_gap', 'grade_adjusted_pace'] },
|
|
528
|
+
];
|
|
529
|
+
/** Containers searched for provider-supplied decoupling/GAP: the payload itself,
|
|
530
|
+
* its common nested analysis/metrics objects, and the stored metrics_json. */
|
|
531
|
+
function decouplingContainers(payload) {
|
|
532
|
+
const containers = [payload];
|
|
533
|
+
for (const key of ['analysis', 'data', 'metrics', 'icuAnalysis', 'summaries']) {
|
|
534
|
+
const nested = payload[key];
|
|
535
|
+
if (nested !== null && typeof nested === 'object' && !Array.isArray(nested))
|
|
536
|
+
containers.push(nested);
|
|
537
|
+
}
|
|
538
|
+
const metricsJson = payload.metrics_json;
|
|
539
|
+
if (metricsJson !== undefined && metricsJson !== null)
|
|
540
|
+
containers.push(objectFromJson(metricsJson));
|
|
541
|
+
return containers;
|
|
542
|
+
}
|
|
543
|
+
/** A3 (persistence): persist provider-supplied aerobic decoupling (Pa:Hr) and
|
|
544
|
+
* grade-adjusted pace from an activity payload so they are queryable historically
|
|
545
|
+
* instead of only inside one activity's raw metrics_json. Provider values are
|
|
546
|
+
* stored as source_type 'provider' and are authoritative. Locally derived values
|
|
547
|
+
* (deriveDecoding/deriveGap) are a separate, still-derived path. */
|
|
548
|
+
async function importActivityDecoupling(database, provider, remoteId, payload, rawHash) {
|
|
549
|
+
const activitySourceId = providerActivityId(provider, remoteId);
|
|
550
|
+
const fields = activityFields(provider, payload);
|
|
551
|
+
const containers = decouplingContainers(payload);
|
|
552
|
+
for (const spec of DECOUPLING_METRICS) {
|
|
553
|
+
let value = null;
|
|
554
|
+
for (const container of containers) {
|
|
555
|
+
const found = firstNumber(container, spec.keys);
|
|
556
|
+
if (found !== null) {
|
|
557
|
+
value = found;
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (value === null)
|
|
562
|
+
continue;
|
|
563
|
+
await database.run(`INSERT INTO activity_decoupling_facts (provider, activity_source_id, sport, started_at_utc, metric, value_number, unit, source_type, caveats_json, raw_object_hash)
|
|
564
|
+
VALUES ($provider, $activitySourceId, $sport, try_cast($startedAtUtc AS TIMESTAMPTZ), $metric, $value, $unit, 'provider', $caveats, $rawHash)
|
|
565
|
+
ON CONFLICT (provider, activity_source_id, metric) DO UPDATE SET
|
|
566
|
+
sport = excluded.sport, started_at_utc = excluded.started_at_utc, value_number = excluded.value_number,
|
|
567
|
+
unit = excluded.unit, source_type = excluded.source_type, caveats_json = excluded.caveats_json, raw_object_hash = excluded.raw_object_hash`, { provider, activitySourceId, sport: fields.sport, startedAtUtc: fields.startAtUtc, metric: spec.metric, value, unit: spec.unit, caveats: json({}), rawHash });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/** Pull a course's ordered geometry samples, tolerating the several shapes a
|
|
571
|
+
* Garmin course payload may use (a flat ``geoPoints`` array on the live course
|
|
572
|
+
* endpoint, legacy ``coursePoints``, or points nested under a geo/route object).
|
|
573
|
+
* Returns an empty list when no geometry is present. */
|
|
574
|
+
function courseGeometryPoints(payload) {
|
|
575
|
+
const candidates = [payload.geoPoints, payload.coursePoints, payload.geo, payload.points, payload.geometry, payload.locations, payload.geoRoute];
|
|
576
|
+
for (const candidate of candidates) {
|
|
577
|
+
if (Array.isArray(candidate))
|
|
578
|
+
return objectArray(candidate);
|
|
579
|
+
if (candidate !== null && typeof candidate === 'object' && !Array.isArray(candidate)) {
|
|
580
|
+
const obj = candidate;
|
|
581
|
+
for (const key of ['coursePoints', 'points', 'locations', 'coursePoint']) {
|
|
582
|
+
if (Array.isArray(obj[key]))
|
|
583
|
+
return objectArray(obj[key]);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return [];
|
|
588
|
+
}
|
|
589
|
+
/** A4: normalize Garmin course geometry into course_geometry so a race's
|
|
590
|
+
* elevation/grade profile can be resolved and diffed. The raw payload is also
|
|
591
|
+
* stored by importSourceEntity, so this only fans the points into rows. Live
|
|
592
|
+
* Garmin courses carry a ``geoPoints`` array (one row per sample); inserts are
|
|
593
|
+
* batched because a full course is roughly two thousand points. */
|
|
594
|
+
async function importGarminCourse(database, remoteId, payload, rawHash) {
|
|
595
|
+
const points = courseGeometryPoints(payload);
|
|
596
|
+
const CHUNK = 500;
|
|
597
|
+
for (let offset = 0; offset < points.length; offset += CHUNK) {
|
|
598
|
+
const chunk = points.slice(offset, offset + CHUNK);
|
|
599
|
+
const values = { courseId: remoteId, rawHash };
|
|
600
|
+
const rows = [];
|
|
601
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
602
|
+
const point = chunk[i];
|
|
603
|
+
const seq = offset + i;
|
|
604
|
+
values[`seq${i}`] = seq;
|
|
605
|
+
values[`lat${i}`] = firstNumber(point, ['latitude', 'lat']);
|
|
606
|
+
values[`lon${i}`] = firstNumber(point, ['longitude', 'lon', 'lng']);
|
|
607
|
+
values[`alt${i}`] = firstNumber(point, ['elevation', 'altitude', 'altitudeMeters', 'ele']);
|
|
608
|
+
values[`dist${i}`] = firstNumber(point, ['distance', 'distanceMeters', 'distance_m']);
|
|
609
|
+
rows.push(`($courseId, $seq${i}, $lat${i}, $lon${i}, $alt${i}, $dist${i}, $rawHash)`);
|
|
610
|
+
}
|
|
611
|
+
await database.run(`INSERT INTO course_geometry (course_id, seq, lat, lon, altitude_m, distance_m, raw_object_hash)
|
|
612
|
+
VALUES ${rows.join(', ')}
|
|
613
|
+
ON CONFLICT (course_id, seq) DO UPDATE SET
|
|
614
|
+
lat = excluded.lat, lon = excluded.lon, altitude_m = excluded.altitude_m,
|
|
615
|
+
distance_m = excluded.distance_m, raw_object_hash = excluded.raw_object_hash`, values);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
522
618
|
async function importGarminTrainingStatus(database, remoteId, occurredOn, payload, rawHash) {
|
|
523
619
|
const statusMap = nested(nested(payload, 'mostRecentTrainingStatus'), 'latestTrainingStatusData');
|
|
524
620
|
const balanceMap = nested(nested(payload, 'mostRecentTrainingLoadBalance'), 'metricsTrainingLoadBalanceDTOMap');
|
|
@@ -852,5 +852,125 @@ const syncRunProgressMigration = {
|
|
|
852
852
|
UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
|
|
853
853
|
`,
|
|
854
854
|
};
|
|
855
|
-
|
|
855
|
+
const canonicalTrainingDerivedAvgPowerMigration = {
|
|
856
|
+
version: 15,
|
|
857
|
+
name: 'canonical_training_derived_avg_power_w',
|
|
858
|
+
sql: `
|
|
859
|
+
-- A2: Stop "NULL avg_power => no power" inference without rewriting provenance.
|
|
860
|
+
-- activity_summaries.avg_power is sparse (NULL for many 2024 running
|
|
861
|
+
-- activities) while authoritative Garmin FIT-derived power is present as
|
|
862
|
+
-- per-duration rows in activity_power_bests (exposed as power_best_facts).
|
|
863
|
+
-- Overwriting the summary value would conflate two layers and break the
|
|
864
|
+
-- provenance contract (summary vs FIT-derived). Instead, extend the
|
|
865
|
+
-- canonical view with a derived column that aggregates the FIT signal:
|
|
866
|
+
-- derived_avg_power_w is the per-activity mean of best_power_w across
|
|
867
|
+
-- all stored durations. Callers should prefer
|
|
868
|
+
-- coalesce(avg_power, derived_avg_power_w) or use power_coverage_report /
|
|
869
|
+
-- read_series on activity_samples for the full signal. NULL remains NULL
|
|
870
|
+
-- when no FIT bests exist for that activity, making absence explicit.
|
|
871
|
+
-- Idempotent via CREATE OR REPLACE VIEW; no table mutation.
|
|
872
|
+
-- Older test fixtures (v9) created activity_summaries with only a subset
|
|
873
|
+
-- of columns; ensure the full canonical set exists so this view's
|
|
874
|
+
-- intervals_analysis (which unconditionally references training_load etc.)
|
|
875
|
+
-- does not throw Binder Error on upgrade.
|
|
876
|
+
ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS elevation_gain_m DOUBLE;
|
|
877
|
+
ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS calories DOUBLE;
|
|
878
|
+
ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS max_hr DOUBLE;
|
|
879
|
+
ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS avg_power DOUBLE;
|
|
880
|
+
ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS weighted_power DOUBLE;
|
|
881
|
+
ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS avg_cadence DOUBLE;
|
|
882
|
+
ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS training_load DOUBLE;
|
|
883
|
+
ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS rpe DOUBLE;
|
|
884
|
+
ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS feel DOUBLE;
|
|
885
|
+
CREATE OR REPLACE VIEW canonical_activity_training AS
|
|
886
|
+
WITH ranked AS (
|
|
887
|
+
SELECT a.*, source.activity_source_id, source.provider, source.remote_activity_id, source.raw_object_hash,
|
|
888
|
+
summary.*, row_number() OVER (
|
|
889
|
+
PARTITION BY a.activity_id
|
|
890
|
+
ORDER BY CASE source.provider WHEN 'garmin' THEN 0 WHEN 'intervals' THEN 1 ELSE 2 END
|
|
891
|
+
) AS source_rank
|
|
892
|
+
FROM activities a
|
|
893
|
+
JOIN activity_sources source USING (activity_id)
|
|
894
|
+
LEFT JOIN activity_summaries summary USING (activity_source_id)
|
|
895
|
+
), intervals_analysis AS (
|
|
896
|
+
SELECT source.activity_id,
|
|
897
|
+
max(summary.training_load) AS intervals_training_load,
|
|
898
|
+
max(summary.rpe) AS intervals_rpe,
|
|
899
|
+
max(summary.feel) AS intervals_feel,
|
|
900
|
+
max(summary.weighted_power) AS intervals_weighted_power
|
|
901
|
+
FROM activity_sources source
|
|
902
|
+
JOIN activity_summaries summary USING (activity_source_id)
|
|
903
|
+
WHERE source.provider = 'intervals'
|
|
904
|
+
GROUP BY source.activity_id
|
|
905
|
+
), derived_power AS (
|
|
906
|
+
SELECT activity_source_id, avg(best_power_w) AS derived_avg_power_w
|
|
907
|
+
FROM activity_power_bests
|
|
908
|
+
GROUP BY activity_source_id
|
|
909
|
+
)
|
|
910
|
+
SELECT ranked.* EXCLUDE (source_rank),
|
|
911
|
+
intervals_analysis.intervals_training_load,
|
|
912
|
+
intervals_analysis.intervals_rpe,
|
|
913
|
+
intervals_analysis.intervals_feel,
|
|
914
|
+
intervals_analysis.intervals_weighted_power,
|
|
915
|
+
derived_power.derived_avg_power_w
|
|
916
|
+
FROM ranked
|
|
917
|
+
LEFT JOIN intervals_analysis USING (activity_id)
|
|
918
|
+
LEFT JOIN derived_power USING (activity_source_id)
|
|
919
|
+
WHERE source_rank = 1;
|
|
920
|
+
|
|
921
|
+
UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
|
|
922
|
+
`,
|
|
923
|
+
};
|
|
924
|
+
const courseGeometryMigration = {
|
|
925
|
+
version: 16,
|
|
926
|
+
name: 'course_geometry',
|
|
927
|
+
sql: `
|
|
928
|
+
-- A4: Garmin course/route geometry so a race's elevation/grade profile can be
|
|
929
|
+
-- resolved and compared against a prior activity's course. garminconnect
|
|
930
|
+
-- 0.3.x has no dedicated course method, so courses are staged from the raw
|
|
931
|
+
-- Connect API (connectapi("course-service/course/{id}"), validated live:
|
|
932
|
+
-- returns metadata + a geoPoints geometry array) and normalized here.
|
|
933
|
+
-- courseId is the Garmin course identifier referenced by an event's courseId.
|
|
934
|
+
CREATE TABLE IF NOT EXISTS course_geometry (
|
|
935
|
+
course_id VARCHAR NOT NULL,
|
|
936
|
+
seq INTEGER NOT NULL,
|
|
937
|
+
lat DOUBLE,
|
|
938
|
+
lon DOUBLE,
|
|
939
|
+
altitude_m DOUBLE,
|
|
940
|
+
distance_m DOUBLE,
|
|
941
|
+
raw_object_hash VARCHAR,
|
|
942
|
+
PRIMARY KEY (course_id, seq)
|
|
943
|
+
);
|
|
944
|
+
|
|
945
|
+
UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
|
|
946
|
+
`,
|
|
947
|
+
};
|
|
948
|
+
const activityDecouplingFactsMigration = {
|
|
949
|
+
version: 17,
|
|
950
|
+
name: 'activity_decoupling_facts',
|
|
951
|
+
sql: `
|
|
952
|
+
-- A3 (persistence): make aerobic decoupling (Pa:Hr) and grade-adjusted pace
|
|
953
|
+
-- (GAP) queryable historically instead of only inside one activity's raw
|
|
954
|
+
-- metrics_json. Rows are provider-supplied when the provider payload carries
|
|
955
|
+
-- them (source_type 'provider'); locally derived values are tagged 'derived'
|
|
956
|
+
-- with caveats_json. Provider values remain authoritative and are never
|
|
957
|
+
-- overwritten by derivation.
|
|
958
|
+
CREATE TABLE IF NOT EXISTS activity_decoupling_facts (
|
|
959
|
+
provider VARCHAR NOT NULL,
|
|
960
|
+
activity_source_id VARCHAR NOT NULL,
|
|
961
|
+
sport VARCHAR,
|
|
962
|
+
started_at_utc TIMESTAMPTZ,
|
|
963
|
+
metric VARCHAR NOT NULL,
|
|
964
|
+
value_number DOUBLE,
|
|
965
|
+
unit VARCHAR,
|
|
966
|
+
source_type VARCHAR NOT NULL,
|
|
967
|
+
caveats_json JSON,
|
|
968
|
+
raw_object_hash VARCHAR,
|
|
969
|
+
PRIMARY KEY (provider, activity_source_id, metric)
|
|
970
|
+
);
|
|
971
|
+
|
|
972
|
+
UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
|
|
973
|
+
`,
|
|
974
|
+
};
|
|
975
|
+
export const migrations = [...baseMigrations, stravaAndIdentityMigration, garminPrimaryAndTrainingMetricsMigration, garminActivitySportAndFtpBackfillMigration, garminPrimaryDailyHealthMigration, garminHistoricalMetricsAndStreamsMigration, garminUtcActivityTimestampMigration, garminLactateThresholdMetricsMigration, swimFactsAndQualityMigration, syncRunProgressMigration, canonicalTrainingDerivedAvgPowerMigration, courseGeometryMigration, activityDecouplingFactsMigration];
|
|
856
976
|
//# sourceMappingURL=migrations.js.map
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { createInterface } from 'node:readline/promises';
|
|
3
3
|
import { stdin as input, stdout as output } from 'node:process';
|
|
4
4
|
import { Command } from 'commander';
|
|
5
|
-
import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, channelForVersion, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, importStagedFile, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, syncProgress, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
|
|
5
|
+
import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, CatenceDatabase, channelForVersion, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, importStagedFile, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, syncProgress, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
|
|
6
6
|
const program = new Command()
|
|
7
7
|
.name('catence-data')
|
|
8
8
|
.description('Athlete-scoped Catence data setup, extraction, and normalization.')
|
|
@@ -277,5 +277,41 @@ program
|
|
|
277
277
|
const runIds = Array.isArray(options.run) ? options.run : options.run ? [options.run] : undefined;
|
|
278
278
|
process.stdout.write(`${JSON.stringify(await resolveExtractionErrors(paths, { runIds, provider: options.provider, before: options.before, all: options.all }), null, 2)}\n`);
|
|
279
279
|
});
|
|
280
|
+
program
|
|
281
|
+
.command('migrate')
|
|
282
|
+
.description('Apply pending DuckDB schema migrations. Without --all it migrates the selected --athlete store; with --all it migrates every athlete in the catalog.')
|
|
283
|
+
.option('--all', 'migrate every athlete in the catalog')
|
|
284
|
+
.option('--home <directory>', 'Catence catalog home directory (also accepts global --home)')
|
|
285
|
+
.action(async (options) => {
|
|
286
|
+
const effectiveHome = options.home ?? program.opts().home;
|
|
287
|
+
const effectiveCatalog = effectiveHome ? resolveCatalogPaths(effectiveHome) : currentCatalog();
|
|
288
|
+
const migratePaths = async (paths) => {
|
|
289
|
+
const database = await CatenceDatabase.open(paths);
|
|
290
|
+
const applied = await database.rows('SELECT version FROM schema_migrations ORDER BY version');
|
|
291
|
+
await database.close();
|
|
292
|
+
return { migrated: true, appliedVersions: applied.map((row) => row.version) };
|
|
293
|
+
};
|
|
294
|
+
if (options.all) {
|
|
295
|
+
const catalog = await loadCatalog(effectiveCatalog);
|
|
296
|
+
const results = {};
|
|
297
|
+
for (const athlete of catalog.athletes) {
|
|
298
|
+
const paths = athleteStorePaths(effectiveCatalog, athlete.id);
|
|
299
|
+
try {
|
|
300
|
+
results[athlete.id] = await migratePaths(paths);
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
results[athlete.id] = { migrated: false, error: error instanceof Error ? error.message : String(error) };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
process.stdout.write(`${JSON.stringify({ catalog: effectiveCatalog.root, results }, null, 2)}\n`);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const athleteId = program.opts().athlete;
|
|
310
|
+
if (!athleteId)
|
|
311
|
+
throw new Error('--athlete is required for this command. Run catence-data athlete list to inspect the catalog or use --all.');
|
|
312
|
+
const { athlete, paths } = await resolveAthlete(effectiveCatalog, athleteId);
|
|
313
|
+
const result = await migratePaths(paths);
|
|
314
|
+
process.stdout.write(`${JSON.stringify({ athleteId: athlete.id, ...result }, null, 2)}\n`);
|
|
315
|
+
});
|
|
280
316
|
await program.parseAsync();
|
|
281
317
|
//# sourceMappingURL=commands.js.map
|