import { parseISO, differenceInMinutes, format } from 'date-fns';
import type {
NormalizedWorkout,
StrainActivity,
MatchedActivity,
ActivityType,
} from '../types/index.js';
// Activity type mappings for normalization across platforms
const ACTIVITY_TYPE_MAP: Record<string, ActivityType> = {
// Intervals.icu types
'ride': 'Cycling',
'cycling': 'Cycling',
'virtualride': 'Cycling',
'run': 'Running',
'running': 'Running',
'virtualrun': 'Running',
'swim': 'Swimming',
'swimming': 'Swimming',
'alpineski': 'Skiing',
'alpine skiing': 'Skiing',
'backcountryski': 'Skiing',
'nordicski': 'Skiing',
'skiing': 'Skiing',
'hike': 'Hiking',
'hiking': 'Hiking',
'rowing': 'Rowing',
'row': 'Rowing',
'weighttraining': 'Strength',
'strength': 'Strength',
'workout': 'Strength',
// Additional Whoop-specific names
'functional fitness': 'Strength',
'hiit': 'Strength',
'cross country skiing': 'Skiing',
'downhill skiing': 'Skiing',
};
/**
* Normalize activity type string to standard ActivityType
*/
export function normalizeActivityType(type: string): ActivityType {
const normalized = type.toLowerCase().replace(/[_-]/g, ' ').trim();
return ACTIVITY_TYPE_MAP[normalized] ?? 'Other';
}
/**
* Check if two activity types are compatible for matching
*/
export function areActivityTypesCompatible(
type1: ActivityType,
type2: ActivityType
): boolean {
// Exact match
if (type1 === type2) return true;
// "Other" matches anything
if (type1 === 'Other' || type2 === 'Other') return true;
return false;
}
/**
* Match workouts across platforms using timestamp and activity type.
* Algorithm:
* 1. High confidence: Start times within 5 minutes AND same activity type
* 2. Medium confidence: Same date AND same activity type
* 3. Low confidence: Same date only
*/
export function matchActivities(
intervalsWorkouts: NormalizedWorkout[],
whoopActivities: StrainActivity[]
): MatchedActivity[] {
const matched: MatchedActivity[] = [];
const usedWhoopIds = new Set<string>();
for (const workout of intervalsWorkouts) {
const workoutStart = parseISO(workout.date);
const workoutDate = format(workoutStart, 'yyyy-MM-dd');
let bestMatch: {
activity: StrainActivity;
confidence: 'high' | 'medium' | 'low';
method: 'timestamp' | 'date_and_type' | 'date_only';
} | null = null;
for (const activity of whoopActivities) {
if (usedWhoopIds.has(activity.id)) continue;
const activityStart = parseISO(activity.start_time);
const activityDate = format(activityStart, 'yyyy-MM-dd');
const timeDiff = Math.abs(differenceInMinutes(workoutStart, activityStart));
const sameType = areActivityTypesCompatible(
workout.activity_type,
activity.activity_type
);
// High confidence: timestamp match + type match
if (timeDiff <= 5 && sameType) {
bestMatch = {
activity,
confidence: 'high',
method: 'timestamp',
};
break; // Found best possible match
}
// Medium confidence: same date + type match
if (workoutDate === activityDate && sameType) {
if (!bestMatch || bestMatch.confidence === 'low') {
bestMatch = {
activity,
confidence: 'medium',
method: 'date_and_type',
};
}
}
// Low confidence: same date only
if (workoutDate === activityDate && !bestMatch) {
bestMatch = {
activity,
confidence: 'low',
method: 'date_only',
};
}
}
if (bestMatch) {
usedWhoopIds.add(bestMatch.activity.id);
matched.push({
intervals_workout: workout,
whoop_activity: bestMatch.activity,
match_confidence: bestMatch.confidence,
match_method: bestMatch.method,
});
} else {
// No match found, include workout without Whoop data
matched.push({
intervals_workout: workout,
match_confidence: 'low',
match_method: 'date_only',
});
}
}
// Add any unmatched Whoop activities
for (const activity of whoopActivities) {
if (!usedWhoopIds.has(activity.id)) {
matched.push({
whoop_activity: activity,
match_confidence: 'low',
match_method: 'date_only',
});
}
}
return matched;
}
/**
* Find a single matching Whoop activity for an Intervals workout
*/
export function findMatchingWhoopActivity(
workout: NormalizedWorkout,
whoopActivities: StrainActivity[]
): StrainActivity | null {
const workoutStart = parseISO(workout.date);
const workoutDate = format(workoutStart, 'yyyy-MM-dd');
// First pass: look for timestamp + type match
for (const activity of whoopActivities) {
const activityStart = parseISO(activity.start_time);
const timeDiff = Math.abs(differenceInMinutes(workoutStart, activityStart));
const sameType = areActivityTypesCompatible(
workout.activity_type,
activity.activity_type
);
if (timeDiff <= 5 && sameType) {
return activity;
}
}
// Second pass: look for date + type match
for (const activity of whoopActivities) {
const activityDate = format(parseISO(activity.start_time), 'yyyy-MM-dd');
const sameType = areActivityTypesCompatible(
workout.activity_type,
activity.activity_type
);
if (workoutDate === activityDate && sameType) {
return activity;
}
}
return null;
}