@clipin/convex-wearables 0.0.2 → 0.0.3
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/client/index.d.ts +9 -4
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +50 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/backfillJobs.d.ts +11 -11
- package/dist/component/connections.d.ts +9 -9
- package/dist/component/connections.d.ts.map +1 -1
- package/dist/component/connections.js +2 -0
- package/dist/component/connections.js.map +1 -1
- package/dist/component/dataPoints.d.ts +5 -5
- package/dist/component/events.d.ts +13 -13
- package/dist/component/garminBackfill.d.ts +2 -2
- package/dist/component/garminWebhooks.d.ts +2 -2
- package/dist/component/garminWebhooks.d.ts.map +1 -1
- package/dist/component/garminWebhooks.js +2 -0
- package/dist/component/garminWebhooks.js.map +1 -1
- package/dist/component/lifecycle.d.ts +1 -1
- package/dist/component/lifecycle.d.ts.map +1 -1
- package/dist/component/lifecycle.js +2 -0
- package/dist/component/lifecycle.js.map +1 -1
- package/dist/component/oauthStates.d.ts +3 -3
- package/dist/component/schema.d.ts +26 -26
- package/dist/component/sdkPush.d.ts +11 -11
- package/dist/component/summaries.d.ts +4 -4
- package/dist/component/syncJobs.d.ts +23 -23
- package/dist/component/syncWorkflow.d.ts +2 -2
- package/dist/test.d.ts +421 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +17 -0
- package/dist/test.js.map +1 -0
- package/package.json +12 -2
- package/src/client/_generated/_ignore.ts +2 -0
- package/src/client/index.test.ts +52 -0
- package/src/client/index.ts +784 -0
- package/src/client/types.ts +533 -0
- package/src/component/_generated/_ignore.ts +2 -0
- package/src/component/_generated/api.ts +16 -0
- package/src/component/_generated/component.ts +74 -0
- package/src/component/_generated/dataModel.ts +40 -0
- package/src/component/_generated/server.ts +48 -0
- package/src/component/backfillJobs.test.ts +47 -0
- package/src/component/backfillJobs.ts +245 -0
- package/src/component/connections.test.ts +297 -0
- package/src/component/connections.ts +329 -0
- package/src/component/convex.config.ts +7 -0
- package/src/component/dataPoints.test.ts +282 -0
- package/src/component/dataPoints.ts +305 -0
- package/src/component/dataSources.test.ts +247 -0
- package/src/component/dataSources.ts +109 -0
- package/src/component/events.test.ts +380 -0
- package/src/component/events.ts +288 -0
- package/src/component/garminBackfill.ts +343 -0
- package/src/component/garminWebhooks.test.ts +609 -0
- package/src/component/garminWebhooks.ts +656 -0
- package/src/component/httpHandlers.ts +153 -0
- package/src/component/lifecycle.test.ts +179 -0
- package/src/component/lifecycle.ts +87 -0
- package/src/component/menstrualCycles.ts +124 -0
- package/src/component/oauthActions.ts +261 -0
- package/src/component/oauthStates.test.ts +170 -0
- package/src/component/oauthStates.ts +85 -0
- package/src/component/providerSettings.ts +66 -0
- package/src/component/providers/additionalProviders.test.ts +401 -0
- package/src/component/providers/garmin.ts +1169 -0
- package/src/component/providers/oauth.test.ts +174 -0
- package/src/component/providers/oauth.ts +246 -0
- package/src/component/providers/polar.ts +220 -0
- package/src/component/providers/registry.ts +37 -0
- package/src/component/providers/strava.test.ts +195 -0
- package/src/component/providers/strava.ts +253 -0
- package/src/component/providers/suunto.ts +592 -0
- package/src/component/providers/types.ts +189 -0
- package/src/component/providers/whoop.ts +600 -0
- package/src/component/schema.ts +339 -0
- package/src/component/sdkPush.test.ts +367 -0
- package/src/component/sdkPush.ts +440 -0
- package/src/component/summaries.test.ts +201 -0
- package/src/component/summaries.ts +143 -0
- package/src/component/syncJobs.test.ts +254 -0
- package/src/component/syncJobs.ts +140 -0
- package/src/component/syncWorkflow.test.ts +87 -0
- package/src/component/syncWorkflow.ts +739 -0
- package/src/component/test.setup.ts +6 -0
- package/src/component/workflowManager.ts +19 -0
- package/src/test.ts +25 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Strava provider normalization logic.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify the pure normalization functions without hitting
|
|
5
|
+
* the Strava API (no network calls).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { normalizeStravaActivity } from "./strava";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Factory helper — builds a minimal valid Strava activity
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function makeActivity(overrides: Record<string, unknown> = {}) {
|
|
16
|
+
return {
|
|
17
|
+
id: 12345,
|
|
18
|
+
name: "Morning Run",
|
|
19
|
+
type: "Run",
|
|
20
|
+
sport_type: "Run",
|
|
21
|
+
start_date: "2026-03-15T07:30:00Z",
|
|
22
|
+
elapsed_time: 1800, // 30 minutes
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Tests
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
describe("Strava normalizeActivity", () => {
|
|
32
|
+
it("normalizes a basic running activity", () => {
|
|
33
|
+
const event = normalizeStravaActivity(makeActivity());
|
|
34
|
+
|
|
35
|
+
expect(event.category).toBe("workout");
|
|
36
|
+
expect(event.type).toBe("running");
|
|
37
|
+
expect(event.externalId).toBe("strava-12345");
|
|
38
|
+
expect(event.durationSeconds).toBe(1800);
|
|
39
|
+
expect(event.sourceName).toBe("Strava");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("calculates start and end timestamps correctly", () => {
|
|
43
|
+
const event = normalizeStravaActivity(
|
|
44
|
+
makeActivity({
|
|
45
|
+
start_date: "2026-03-15T10:00:00Z",
|
|
46
|
+
elapsed_time: 3600, // 1 hour
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const expectedStart = new Date("2026-03-15T10:00:00Z").getTime();
|
|
51
|
+
expect(event.startDatetime).toBe(expectedStart);
|
|
52
|
+
expect(event.endDatetime).toBe(expectedStart + 3600 * 1000);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("maps sport_type to unified workout type", () => {
|
|
56
|
+
const cases: [string, string, string][] = [
|
|
57
|
+
["Ride", "Ride", "cycling"],
|
|
58
|
+
["MountainBikeRide", "Ride", "mountain_biking"],
|
|
59
|
+
["Swim", "Swim", "swimming"],
|
|
60
|
+
["Hike", "Hike", "hiking"],
|
|
61
|
+
["WeightTraining", "WeightTraining", "strength_training"],
|
|
62
|
+
["Yoga", "Yoga", "yoga"],
|
|
63
|
+
["AlpineSki", "AlpineSki", "alpine_skiing"],
|
|
64
|
+
["Rowing", "Rowing", "rowing"],
|
|
65
|
+
["VirtualRide", "Ride", "indoor_cycling"],
|
|
66
|
+
["TrailRun", "Run", "trail_running"],
|
|
67
|
+
["Pickleball", "Pickleball", "pickleball"],
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
for (const [sportType, activityType, expected] of cases) {
|
|
71
|
+
const event = normalizeStravaActivity(
|
|
72
|
+
makeActivity({ sport_type: sportType, type: activityType }),
|
|
73
|
+
);
|
|
74
|
+
expect(event.type).toBe(expected);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("falls back to activity type when sport_type is unknown", () => {
|
|
79
|
+
const event = normalizeStravaActivity(makeActivity({ sport_type: "SomeNewType", type: "Run" }));
|
|
80
|
+
expect(event.type).toBe("running");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns 'other' for completely unknown types", () => {
|
|
84
|
+
const event = normalizeStravaActivity(
|
|
85
|
+
makeActivity({ sport_type: "SomeNewType", type: "SomeOtherType" }),
|
|
86
|
+
);
|
|
87
|
+
expect(event.type).toBe("other");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("includes heart rate data when available", () => {
|
|
91
|
+
const event = normalizeStravaActivity(
|
|
92
|
+
makeActivity({
|
|
93
|
+
average_heartrate: 145,
|
|
94
|
+
max_heartrate: 172,
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(event.heartRateAvg).toBe(145);
|
|
99
|
+
expect(event.heartRateMax).toBe(172);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("includes distance and speed data", () => {
|
|
103
|
+
const event = normalizeStravaActivity(
|
|
104
|
+
makeActivity({
|
|
105
|
+
distance: 10000, // 10km in meters
|
|
106
|
+
average_speed: 3.5, // m/s
|
|
107
|
+
max_speed: 4.2,
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(event.distance).toBe(10000);
|
|
112
|
+
expect(event.averageSpeed).toBe(3.5);
|
|
113
|
+
expect(event.maxSpeed).toBe(4.2);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("includes elevation data", () => {
|
|
117
|
+
const event = normalizeStravaActivity(
|
|
118
|
+
makeActivity({
|
|
119
|
+
total_elevation_gain: 250,
|
|
120
|
+
elev_high: 1200,
|
|
121
|
+
elev_low: 950,
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
expect(event.totalElevationGain).toBe(250);
|
|
126
|
+
expect(event.elevHigh).toBe(1200);
|
|
127
|
+
expect(event.elevLow).toBe(950);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("includes power data", () => {
|
|
131
|
+
const event = normalizeStravaActivity(
|
|
132
|
+
makeActivity({
|
|
133
|
+
average_watts: 220,
|
|
134
|
+
max_watts: 450,
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
expect(event.averageWatts).toBe(220);
|
|
139
|
+
expect(event.maxWatts).toBe(450);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("prefers calories over kilojoules for energy", () => {
|
|
143
|
+
const event = normalizeStravaActivity(
|
|
144
|
+
makeActivity({
|
|
145
|
+
calories: 500,
|
|
146
|
+
kilojoules: 2000,
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
expect(event.energyBurned).toBe(500);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("converts kilojoules to kcal when calories not available", () => {
|
|
154
|
+
const event = normalizeStravaActivity(
|
|
155
|
+
makeActivity({
|
|
156
|
+
kilojoules: 1000,
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// 1000 * 0.239 = 239
|
|
161
|
+
expect(event.energyBurned).toBeCloseTo(239, 0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("includes moving time", () => {
|
|
165
|
+
const event = normalizeStravaActivity(
|
|
166
|
+
makeActivity({
|
|
167
|
+
moving_time: 1500,
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(event.movingTimeSeconds).toBe(1500);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("uses device_name as sourceName when available", () => {
|
|
175
|
+
const event = normalizeStravaActivity(
|
|
176
|
+
makeActivity({
|
|
177
|
+
device_name: "Garmin Edge 1040",
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(event.sourceName).toBe("Garmin Edge 1040");
|
|
182
|
+
expect(event.deviceModel).toBe("Garmin Edge 1040");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("omits optional fields when not present in activity", () => {
|
|
186
|
+
const event = normalizeStravaActivity(makeActivity());
|
|
187
|
+
|
|
188
|
+
expect(event.heartRateAvg).toBeUndefined();
|
|
189
|
+
expect(event.heartRateMax).toBeUndefined();
|
|
190
|
+
expect(event.distance).toBeUndefined();
|
|
191
|
+
expect(event.energyBurned).toBeUndefined();
|
|
192
|
+
expect(event.averageWatts).toBeUndefined();
|
|
193
|
+
expect(event.deviceModel).toBeUndefined();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strava provider adapter.
|
|
3
|
+
*
|
|
4
|
+
* Fetches activities from Strava API and normalizes them to our event format.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { makeAuthenticatedRequest } from "./oauth";
|
|
8
|
+
import type {
|
|
9
|
+
NormalizedEvent,
|
|
10
|
+
OAuthProviderConfig,
|
|
11
|
+
ProviderAdapter,
|
|
12
|
+
ProviderCredentials,
|
|
13
|
+
ProviderUserInfo,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// OAuth config
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export function stravaOAuthConfig(credentials: ProviderCredentials): OAuthProviderConfig {
|
|
21
|
+
return {
|
|
22
|
+
endpoints: {
|
|
23
|
+
authorizeUrl: "https://www.strava.com/oauth/authorize",
|
|
24
|
+
tokenUrl: "https://www.strava.com/api/v3/oauth/token",
|
|
25
|
+
apiBaseUrl: "https://www.strava.com/api/v3",
|
|
26
|
+
},
|
|
27
|
+
clientId: credentials.clientId,
|
|
28
|
+
clientSecret: credentials.clientSecret,
|
|
29
|
+
defaultScope: "activity:read_all,profile:read_all",
|
|
30
|
+
usePkce: false,
|
|
31
|
+
authMethod: "body",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Strava API types
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
interface StravaActivity {
|
|
40
|
+
id: number;
|
|
41
|
+
name: string;
|
|
42
|
+
type: string;
|
|
43
|
+
sport_type: string;
|
|
44
|
+
start_date: string; // ISO 8601 UTC
|
|
45
|
+
elapsed_time: number; // seconds
|
|
46
|
+
distance?: number; // meters
|
|
47
|
+
moving_time?: number;
|
|
48
|
+
total_elevation_gain?: number;
|
|
49
|
+
elev_high?: number;
|
|
50
|
+
elev_low?: number;
|
|
51
|
+
average_heartrate?: number;
|
|
52
|
+
max_heartrate?: number;
|
|
53
|
+
average_speed?: number; // m/s
|
|
54
|
+
max_speed?: number;
|
|
55
|
+
average_watts?: number;
|
|
56
|
+
max_watts?: number;
|
|
57
|
+
kilojoules?: number;
|
|
58
|
+
calories?: number;
|
|
59
|
+
device_name?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Workout type mapping (Strava sport_type → unified type)
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
const WORKOUT_TYPE_MAP: Record<string, string> = {
|
|
67
|
+
Run: "running",
|
|
68
|
+
TrailRun: "trail_running",
|
|
69
|
+
VirtualRun: "running",
|
|
70
|
+
Ride: "cycling",
|
|
71
|
+
MountainBikeRide: "mountain_biking",
|
|
72
|
+
GravelRide: "cycling",
|
|
73
|
+
EBikeRide: "e_biking",
|
|
74
|
+
EMountainBikeRide: "e_biking",
|
|
75
|
+
VirtualRide: "indoor_cycling",
|
|
76
|
+
Swim: "swimming",
|
|
77
|
+
Walk: "walking",
|
|
78
|
+
Hike: "hiking",
|
|
79
|
+
AlpineSki: "alpine_skiing",
|
|
80
|
+
BackcountrySki: "backcountry_skiing",
|
|
81
|
+
NordicSki: "cross_country_skiing",
|
|
82
|
+
Snowboard: "snowboarding",
|
|
83
|
+
Snowshoe: "snowshoeing",
|
|
84
|
+
IceSkate: "ice_skating",
|
|
85
|
+
Rowing: "rowing",
|
|
86
|
+
Kayaking: "kayaking",
|
|
87
|
+
Canoeing: "canoeing",
|
|
88
|
+
StandUpPaddling: "stand_up_paddleboarding",
|
|
89
|
+
Surfing: "surfing",
|
|
90
|
+
Kitesurf: "kitesurfing",
|
|
91
|
+
Windsurf: "windsurfing",
|
|
92
|
+
Sail: "sailing",
|
|
93
|
+
WeightTraining: "strength_training",
|
|
94
|
+
Yoga: "yoga",
|
|
95
|
+
Pilates: "pilates",
|
|
96
|
+
Crossfit: "cardio_training",
|
|
97
|
+
Elliptical: "elliptical",
|
|
98
|
+
StairStepper: "stair_climbing",
|
|
99
|
+
HighIntensityIntervalTraining: "cardio_training",
|
|
100
|
+
Pickleball: "pickleball",
|
|
101
|
+
Squash: "squash",
|
|
102
|
+
Badminton: "badminton",
|
|
103
|
+
TableTennis: "table_tennis",
|
|
104
|
+
Tennis: "tennis",
|
|
105
|
+
Soccer: "soccer",
|
|
106
|
+
RockClimbing: "rock_climbing",
|
|
107
|
+
Golf: "golf",
|
|
108
|
+
Skateboard: "skateboarding",
|
|
109
|
+
InlineSkate: "inline_skating",
|
|
110
|
+
VirtualRow: "rowing_machine",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
function getUnifiedWorkoutType(sportType: string, activityType: string): string {
|
|
114
|
+
return WORKOUT_TYPE_MAP[sportType] ?? WORKOUT_TYPE_MAP[activityType] ?? "other";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Normalization
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
function normalizeActivity(activity: StravaActivity): NormalizedEvent {
|
|
122
|
+
const startMs = new Date(activity.start_date).getTime();
|
|
123
|
+
const endMs = startMs + activity.elapsed_time * 1000;
|
|
124
|
+
|
|
125
|
+
// Energy: prefer calories, fallback to kilojoules * 0.239
|
|
126
|
+
let energyBurned: number | undefined;
|
|
127
|
+
if (activity.calories != null) {
|
|
128
|
+
energyBurned = activity.calories;
|
|
129
|
+
} else if (activity.kilojoules != null) {
|
|
130
|
+
energyBurned = activity.kilojoules * 0.239;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
category: "workout",
|
|
135
|
+
type: getUnifiedWorkoutType(activity.sport_type, activity.type),
|
|
136
|
+
sourceName: activity.device_name ?? "Strava",
|
|
137
|
+
deviceModel: activity.device_name,
|
|
138
|
+
durationSeconds: activity.elapsed_time,
|
|
139
|
+
startDatetime: startMs,
|
|
140
|
+
endDatetime: endMs,
|
|
141
|
+
externalId: `strava-${activity.id}`,
|
|
142
|
+
|
|
143
|
+
heartRateAvg: activity.average_heartrate,
|
|
144
|
+
heartRateMax: activity.max_heartrate,
|
|
145
|
+
energyBurned,
|
|
146
|
+
distance: activity.distance,
|
|
147
|
+
averageSpeed: activity.average_speed,
|
|
148
|
+
maxSpeed: activity.max_speed,
|
|
149
|
+
averageWatts: activity.average_watts,
|
|
150
|
+
maxWatts: activity.max_watts,
|
|
151
|
+
totalElevationGain: activity.total_elevation_gain,
|
|
152
|
+
elevHigh: activity.elev_high,
|
|
153
|
+
elevLow: activity.elev_low,
|
|
154
|
+
movingTimeSeconds: activity.moving_time,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Fetch workouts
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
const API_BASE = "https://www.strava.com/api/v3";
|
|
163
|
+
const PER_PAGE = 200;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Fetch all activities from Strava within a date range.
|
|
167
|
+
* Uses page-based pagination, fetching up to 200 per page.
|
|
168
|
+
*/
|
|
169
|
+
export async function fetchStravaWorkouts(
|
|
170
|
+
accessToken: string,
|
|
171
|
+
startDate: number,
|
|
172
|
+
endDate: number,
|
|
173
|
+
_credentials?: ProviderCredentials,
|
|
174
|
+
): Promise<NormalizedEvent[]> {
|
|
175
|
+
const allEvents: NormalizedEvent[] = [];
|
|
176
|
+
let page = 1;
|
|
177
|
+
|
|
178
|
+
const after = Math.floor(startDate / 1000);
|
|
179
|
+
const before = Math.floor(endDate / 1000);
|
|
180
|
+
|
|
181
|
+
while (true) {
|
|
182
|
+
const activities = await makeAuthenticatedRequest<StravaActivity[]>(
|
|
183
|
+
API_BASE,
|
|
184
|
+
"/athlete/activities",
|
|
185
|
+
accessToken,
|
|
186
|
+
{
|
|
187
|
+
params: {
|
|
188
|
+
after: String(after),
|
|
189
|
+
before: String(before),
|
|
190
|
+
page: String(page),
|
|
191
|
+
per_page: String(PER_PAGE),
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
for (const activity of activities) {
|
|
197
|
+
try {
|
|
198
|
+
allEvents.push(normalizeActivity(activity));
|
|
199
|
+
} catch {
|
|
200
|
+
// Skip activities that fail to normalize
|
|
201
|
+
console.warn(`Failed to normalize Strava activity ${activity.id}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Last page when we get fewer than per_page results
|
|
206
|
+
if (activities.length < PER_PAGE) break;
|
|
207
|
+
page++;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return allEvents;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Normalize a single Strava activity (for webhook push processing).
|
|
215
|
+
*/
|
|
216
|
+
export function normalizeStravaActivity(activityJson: StravaActivity): NormalizedEvent {
|
|
217
|
+
return normalizeActivity(activityJson);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Provider user info
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Fetch the authenticated athlete's profile from Strava.
|
|
226
|
+
*/
|
|
227
|
+
export async function getStravaUserInfo(
|
|
228
|
+
accessToken: string,
|
|
229
|
+
_tokenResponse?: unknown,
|
|
230
|
+
_appUserId?: string,
|
|
231
|
+
_credentials?: ProviderCredentials,
|
|
232
|
+
): Promise<ProviderUserInfo> {
|
|
233
|
+
try {
|
|
234
|
+
const athlete = await makeAuthenticatedRequest<{
|
|
235
|
+
id: number;
|
|
236
|
+
username?: string;
|
|
237
|
+
}>(API_BASE, "/athlete", accessToken);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
providerUserId: String(athlete.id),
|
|
241
|
+
username: athlete.username ?? null,
|
|
242
|
+
};
|
|
243
|
+
} catch {
|
|
244
|
+
return { providerUserId: null, username: null };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export const stravaProvider: ProviderAdapter = {
|
|
249
|
+
name: "strava",
|
|
250
|
+
oauthConfig: stravaOAuthConfig,
|
|
251
|
+
getUserInfo: getStravaUserInfo,
|
|
252
|
+
fetchEvents: fetchStravaWorkouts,
|
|
253
|
+
};
|