From b050c45fb938d64426b877f0ea33b5dabbb357cc Mon Sep 17 00:00:00 2001 From: sumulige Date: Sun, 11 Jan 2026 12:49:18 +0800 Subject: [PATCH] feat: Add Activity Streams, Intervals, and Bulk Operations support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Activity Streams API (get, update from JSON/CSV) - Add Activity Intervals API (get, update, delete, split) - Add Bulk Operations (createEventsBulk, deleteEventsBulk, updateEventsRange, etc.) - Add comprehensive TypeScript types for new features - Export new types in index.ts New methods: - getActivityStreams(), updateActivityStreams(), updateActivityStreamsFromCSV() - getActivityIntervals(), updateActivityIntervals(), deleteActivityIntervals() - splitInterval(), updateInterval(), getActivityWithIntervals() - createEventsBulk(), deleteEventsBulk(), deleteEventsRange() - updateEventsRange(), updateWellnessBulk() 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 13 +- src/client.ts | 472 +++++++++++++++++++++++++++++++++++++++++++++- src/index.ts | 17 +- src/types.ts | 158 +++++++++++++++- 4 files changed, 648 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6dc5d7b..01b4254 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "intervals-icu", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "intervals-icu", - "version": "1.1.0", + "version": "1.1.1", "license": "MIT", "dependencies": { "axios": "^1.6.0" @@ -1020,6 +1020,7 @@ "integrity": "sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1073,6 +1074,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -1369,6 +1371,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1843,6 +1846,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -1898,6 +1902,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3066,6 +3071,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3517,6 +3523,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3692,6 +3699,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3730,6 +3738,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/src/client.ts b/src/client.ts index fee7b29..8600d6d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -12,6 +12,15 @@ import type { Activity, ActivityInput, PaginationOptions, + ActivityStream, + StreamsOptions, + UpdateStreamsResult, + Interval, + IntervalsDTO, + UpdateIntervalsOptions, + BulkEventInput, + DoomedEvent, + DeleteEventsResponse, } from './types.js'; /** @@ -599,10 +608,10 @@ export class IntervalsClient { /** * Deletes an activity - * + * * @param activityId - Activity ID * @param athleteId - Athlete ID (defaults to the configured athlete or 'me') - * + * * @example * ```typescript * await client.deleteActivity(12345); @@ -615,4 +624,463 @@ export class IntervalsClient { url: `/athlete/${id}/activities/${activityId}`, }); } + + // ==================== ACTIVITY STREAMS ENDPOINTS ==================== + + /** + * Gets activity streams (time-series data) + * + * @param activityId - Activity ID + * @param options - Stream options (types, format, etc.) + * @returns Array of activity streams with data + * + * @example + * ```typescript + * // Get all streams for an activity + * const streams = await client.getActivityStreams(12345); + * + * // Get specific stream types + * const powerAndHr = await client.getActivityStreams(12345, { + * types: ['watts', 'heartrate'] + * }); + * + * // Get streams as CSV + * const csvData = await client.getActivityStreams(12345, { + * types: 'watts', + * format: 'csv' + * }); + * ``` + */ + public async getActivityStreams( + activityId: number, + options?: StreamsOptions + ): Promise { + const params: Record = {}; + + if (options?.types) { + if (Array.isArray(options.types)) { + params.types = options.types.join(','); + } else { + params.types = options.types; + } + } + if (options?.includeDefaults !== undefined) { + params.includeDefaults = options.includeDefaults; + } + + const extension = options?.format === 'csv' ? '.csv' : '.json'; + return this.request({ + method: 'GET', + url: `/activity/${activityId}/streams${extension}`, + params, + }); + } + + /** + * Updates activity streams from JSON + * + * @param activityId - Activity ID + * @param streams - Array of streams to update + * @returns Update result + * + * @example + * ```typescript + * const result = await client.updateActivityStreams(12345, [ + * { type: 'watts', data: [100, 150, 200, ...] }, + * { type: 'heartrate', data: [120, 130, 140, ...] } + * ]); + * ``` + */ + public async updateActivityStreams( + activityId: number, + streams: ActivityStream[] + ): Promise { + return this.request({ + method: 'PUT', + url: `/activity/${activityId}/streams`, + data: streams, + }); + } + + /** + * Updates activity streams from CSV + * + * @param activityId - Activity ID + * @param csvFile - CSV file content (multipart form data) + * @returns Update result + * + * @example + * ```typescript + * const formData = new FormData(); + * formData.append('file', csvFileBlob); + * const result = await client.updateActivityStreamsFromCSV(12345, formData); + * ``` + */ + public async updateActivityStreamsFromCSV( + activityId: number, + csvFile: FormData + ): Promise { + const response = await this.httpClient.request({ + method: 'PUT', + url: `/activity/${activityId}/streams.csv`, + data: csvFile, + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + return response.data; + } + + // ==================== ACTIVITY INTERVALS ENDPOINTS ==================== + + /** + * Gets activity intervals/laps + * + * @param activityId - Activity ID + * @returns Intervals data with laps + * + * @example + * ```typescript + * const intervals = await client.getActivityIntervals(12345); + * console.log(`Found ${intervals.count} intervals`); + * intervals.intervals.forEach(interval => { + * console.log(`${interval.name}: ${interval.average_watts}W avg`); + * }); + * ``` + */ + public async getActivityIntervals(activityId: number): Promise { + return this.request({ + method: 'GET', + url: `/activity/${activityId}/intervals`, + }); + } + + /** + * Updates activity intervals + * + * @param activityId - Activity ID + * @param intervals - Array of intervals to set + * @param options - Update options (all=true replaces all intervals) + * @returns Updated intervals data + * + * @example + * ```typescript + * const intervals = await client.updateActivityIntervals(12345, [ + * { + * start_index: 0, + * end_index: 600, + * name: 'Warm up', + * elapsed_time: 600 + * }, + * { + * start_index: 600, + * end_index: 1800, + * name: 'Main set', + * elapsed_time: 1200 + * } + * ], { all: true }); + * ``` + */ + public async updateActivityIntervals( + activityId: number, + intervals: Interval[], + options?: UpdateIntervalsOptions + ): Promise { + const params: Record = {}; + if (options?.all !== undefined) { + params.all = options.all; + } + + return this.request({ + method: 'PUT', + url: `/activity/${activityId}/intervals`, + params, + data: intervals, + }); + } + + /** + * Deletes specific intervals from an activity + * + * @param activityId - Activity ID + * @param intervals - Intervals to delete + * @returns Remaining intervals + * + * @example + * ```typescript + * const result = await client.deleteActivityIntervals(12345, [ + * { id: 1, start_index: 0, end_index: 600 }, + * { id: 2, start_index: 600, end_index: 1200 } + * ]); + * ``` + */ + public async deleteActivityIntervals( + activityId: number, + intervals: Interval[] + ): Promise { + return this.request({ + method: 'PUT', + url: `/activity/${activityId}/delete-intervals`, + data: intervals, + }); + } + + /** + * Splits an interval at a specific index + * + * @param activityId - Activity ID + * @param splitAt - Index to split the interval at + * @returns Updated intervals + * + * @example + * ```typescript + * const result = await client.splitInterval(12345, 600); + * ``` + */ + public async splitInterval( + activityId: number, + splitAt: number + ): Promise { + return this.request({ + method: 'PUT', + url: `/activity/${activityId}/split-interval`, + params: { splitAt }, + }); + } + + /** + * Updates or creates a specific interval + * + * @param activityId - Activity ID + * @param intervalId - Interval ID + * @param interval - Interval data + * @returns Updated intervals + * + * @example + * ```typescript + * const result = await client.updateInterval(12345, 1, { + * start_index: 0, + * end_index: 600, + * name: 'Updated interval name' + * }); + * ``` + */ + public async updateInterval( + activityId: number, + intervalId: number, + interval: Interval + ): Promise { + return this.request({ + method: 'PUT', + url: `/activity/${activityId}/intervals/${intervalId}`, + data: interval, + }); + } + + /** + * Gets activity with intervals included + * + * @param activityId - Activity ID + * @returns Activity with intervals data + * + * @example + * ```typescript + * const activity = await client.getActivityWithIntervals(12345); + * console.log(activity.name, activity.intervals); + * ``` + */ + public async getActivityWithIntervals( + activityId: number + ): Promise { + return this.request({ + method: 'GET', + url: `/activity/${activityId}`, + params: { intervals: true }, + }); + } + + // ==================== BULK EVENTS OPERATIONS ==================== + + /** + * Creates multiple events at once + * + * @param events - Array of events to create + * @param athleteId - Athlete ID (defaults to configured athlete) + * @param options - Bulk options (upsertOnUid) + * @returns Array of created events + * + * @example + * ```typescript + * const events = await client.createEventsBulk([ + * { + * start_date_local: '2024-01-15', + * name: 'Morning Workout', + * category: 'WORKOUT' + * }, + * { + * start_date_local: '2024-01-16', + * name: 'Evening Workout', + * category: 'WORKOUT' + * } + * ]); + * ``` + */ + public async createEventsBulk( + events: BulkEventInput[], + athleteId?: string, + options?: { upsertOnUid?: boolean } + ): Promise { + const id = athleteId || this.athleteId; + const params: Record = {}; + if (options?.upsertOnUid !== undefined) { + params.upsertOnUid = options.upsertOnUid; + } + + // Use Promise.all to create all events in parallel + const promises = events.map(event => + this.request({ + method: 'POST', + url: `/athlete/${id}/events`, + params, + data: event, + }) + ); + + return Promise.all(promises); + } + + /** + * Deletes multiple events by ID or external_id + * + * @param doomedEvents - Array of events to delete (with id or external_id) + * @param athleteId - Athlete ID (defaults to configured athlete) + * @returns Delete response with deleted count and IDs + * + * @example + * ```typescript + * // Delete by internal IDs + * const result = await client.deleteEventsBulk([ + * { id: 12345 }, + * { id: 12346 } + * ]); + * + * // Delete by external IDs + * const result = await client.deleteEventsBulk([ + * { external_id: 'ext-001' }, + * { external_id: 'ext-002' } + * ]); + * ``` + */ + public async deleteEventsBulk( + doomedEvents: DoomedEvent[], + athleteId?: string + ): Promise { + const id = athleteId || this.athleteId; + return this.request({ + method: 'PUT', + url: `/athlete/${id}/events/bulk-delete`, + data: doomedEvents, + }); + } + + /** + * Deletes a range of events by category + * + * @param options - Delete options (oldest, newest, category, etc.) + * @param athleteId - Athlete ID (defaults to configured athlete) + * + * @example + * ```typescript + * // Delete all workouts in January 2024 + * await client.deleteEventsRange({ + * oldest: '2024-01-01', + * newest: '2024-01-31', + * category: ['WORKOUT'] + * }); + * + * // Delete all future events created by specific athlete + * await client.deleteEventsRange({ + * oldest: new Date().toISOString().split('T')[0], + * category: ['WORKOUT', 'NOTE'], + * createdById: 'coach_athlete_id' + * }); + * ``` + */ + public async deleteEventsRange( + options: { + oldest: string; + newest?: string; + category: string[]; + createdById?: string; + }, + athleteId?: string + ): Promise { + const id = athleteId || this.athleteId; + await this.request({ + method: 'DELETE', + url: `/athlete/${id}/events`, + params: options, + }); + } + + /** + * Updates multiple events in a date range at once + * Only hide_from_athlete and athlete_cannot_edit can be updated + * + * @param oldest - Oldest date to update (ISO-8601) + * @param newest - Newest date to update (ISO-8601) + * @param data - Event data to update (only hide_from_athlete, athlete_cannot_edit) + * @param athleteId - Athlete ID (defaults to configured athlete) + * @returns Array of updated events + * + * @example + * ```typescript + * const updated = await client.updateEventsRange( + * '2024-01-01', + * '2024-01-31', + * { hide_from_athlete: true } + * ); + * ``` + */ + public async updateEventsRange( + oldest: string, + newest: string, + data: Partial>, + athleteId?: string + ): Promise { + const id = athleteId || this.athleteId; + return this.request({ + method: 'PUT', + url: `/athlete/${id}/events`, + params: { oldest, newest }, + data, + }); + } + + /** + * Updates wellness records in bulk + * + * @param wellnessRecords - Array of wellness records to update + * @param athleteId - Athlete ID (defaults to configured athlete) + * + * @example + * ```typescript + * await client.updateWellnessBulk([ + * { id: '2024-01-15', weight: 70.5, restingHR: 52 }, + * { id: '2024-01-16', weight: 70.3, restingHR: 51 }, + * { id: '2024-01-17', weight: 70.1, restingHR: 50 } + * ]); + * ``` + */ + public async updateWellnessBulk( + wellnessRecords: Wellness[], + athleteId?: string + ): Promise { + const id = athleteId || this.athleteId; + await this.request({ + method: 'PUT', + url: `/athlete/${id}/wellness-bulk`, + data: wellnessRecords, + }); + } } diff --git a/src/index.ts b/src/index.ts index 0bc727c..6527778 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ /** * node-intervals-icu - * + * * A lightweight TypeScript client library for the Intervals.icu API. - * + * * @packageDocumentation */ @@ -20,4 +20,17 @@ export type { Activity, ActivityInput, PaginationOptions, + // Activity Streams + ActivityStream, + StreamType, + StreamsOptions, + UpdateStreamsResult, + // Activity Intervals + Interval, + IntervalsDTO, + UpdateIntervalsOptions, + // Bulk Operations + BulkEventInput, + DoomedEvent, + DeleteEventsResponse, } from './types.js'; diff --git a/src/types.ts b/src/types.ts index 6f79322..98bbf11 100644 --- a/src/types.ts +++ b/src/types.ts @@ -49,6 +49,10 @@ export interface Event { description?: string; color?: string; show_as_note?: boolean; + athlete_cannot_edit?: boolean; + hide_from_athlete?: boolean; + external_id?: string; + uid?: string; created?: string; updated?: string; } @@ -152,6 +156,148 @@ export interface Activity { updated?: string; } +// ==================== ACTIVITY STREAMS TYPES ==================== + +/** + * Activity Stream data + * Represents time-series data for an activity (power, heart rate, cadence, etc.) + */ +export interface ActivityStream { + /** Stream type (e.g., 'watts', 'heartrate', 'cadence', 'speed', 'distance', 'altitude', 'latlng', 'time', 'moving') */ + type: string; + /** Data points as an array of values */ + data: number[] | number[][]; + /** Sample rate in Hz (if applicable) */ + sample_rate?: number; +} + +/** + * Available stream types for activities + */ +export type StreamType = + | 'watts' // Power in watts + | 'heartrate' // Heart rate in bpm + | 'cadence' // Cadence in rpm + | 'speed' // Speed in m/s + | 'distance' // Distance in meters + | 'altitude' // Altitude in meters + | 'latlng' // GPS coordinates [latitude, longitude] + | 'time' // Time in seconds + | 'moving' // Moving state (boolean as 0/1) + | 'grade' // Grade percentage + | 'velocity_smooth' // Smoothed velocity in m/s + | 'temp' // Temperature in Celsius + | 'watts_left' // Left balance power + | 'watts_right' // Right balance power + | 'watts_sample'; // Sample rate for power + +/** + * Options for getting activity streams + */ +export interface StreamsOptions { + /** Comma-separated list of stream types to retrieve */ + types?: StreamType | StreamType[]; + /** Include default stream types */ + includeDefaults?: boolean; + /** Return data in CSV format (instead of JSON) */ + format?: 'json' | 'csv'; +} + +/** + * Result of updating streams + */ +export interface UpdateStreamsResult { + success: boolean; + updated: string[]; + errors?: string[]; +} + +// ==================== ACTIVITY INTERVALS TYPES ==================== + +/** + * Activity Interval/Lap data + */ +export interface Interval { + id?: number; + /** Interval start index in the stream data */ + start_index: number; + /** Interval end index in the stream data */ + end_index: number; + /** Interval duration in seconds */ + elapsed_time?: number; + /** Moving time in seconds */ + moving_time?: number; + /** Distance in meters */ + distance?: number; + /** Average power in watts */ + average_watts?: number; + /** Average heart rate in bpm */ + average_heartrate?: number; + /** Average cadence in rpm */ + average_cadence?: number; + /** Max power in watts */ + max_watts?: number; + /** Max heart rate in bpm */ + max_heartrate?: number; + /** TSS score */ + tss?: number; + /** Normalized power */ + np?: number; + /** Intensity factor */ + intensity?: number; + /** Name/description of the interval */ + name?: string; +} + +/** + * Response containing activity intervals + */ +export interface IntervalsDTO { + activity_id: string; + intervals: Interval[]; + /** Count of intervals */ + count: number; +} + +/** + * Options for updating intervals + */ +export interface UpdateIntervalsOptions { + /** Replace all existing intervals (default: true) */ + all?: boolean; +} + +// ==================== BULK OPERATIONS TYPES ==================== + +/** + * Event for bulk deletion + */ +export interface DoomedEvent { + id?: number; + external_id?: string; +} + +/** + * Response from bulk event deletion + */ +export interface DeleteEventsResponse { + deleted: number; + ids: number[]; + external_ids: string[]; +} + +/** + * Options for creating events in bulk + */ +export interface BulkEventInput extends EventInput { + /** External ID for upsert operations */ + external_id?: string; + /** Unique identifier for upsert */ + uid?: string; +} + +// ==================== ORIGINAL TYPES ==================== + /** * Configuration for the Intervals.icu client */ @@ -160,17 +306,17 @@ export interface IntervalsConfig { * API key for authentication */ apiKey: string; - + /** * Athlete ID (defaults to 'me' for the authenticated athlete) */ athleteId?: string; - + /** * Base URL for the API (defaults to https://intervals.icu/api/v1) */ baseURL?: string; - + /** * Request timeout in milliseconds (defaults to 10000) */ @@ -194,17 +340,17 @@ export interface PaginationOptions { * Oldest date to return (ISO 8601 format) */ oldest?: string; - + /** * Newest date to return (ISO 8601 format) */ newest?: string; - + /** * Limit the number of results */ limit?: number; - + /** * Offset for pagination */