Skip to content

Commit 47239e9

Browse files
committed
Unit tests now all pass again, fix date parity by adding in datetime formatting to ensure consistency between python and typescript
1 parent 9f5af2d commit 47239e9

9 files changed

Lines changed: 200 additions & 90 deletions

File tree

integration_test/typescript/integration-test-typescript.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { writeFileSync, readFileSync, existsSync } from 'fs';
88
import { join } from 'path';
99
import { Otf } from '../../src/otf';
10+
import { formatDateToPythonISO } from '../../src/utils/datetime';
1011

1112
function loadEnvFile(): void {
1213
/**
@@ -104,7 +105,7 @@ async function runTypeScriptIntegrationTests(): Promise<IntegrationTestResults |
104105
console.log(`🔷 Running TypeScript integration tests for ${email}`);
105106

106107
const results: IntegrationTestResults = {
107-
timestamp: new Date().toISOString(),
108+
timestamp: formatDateToPythonISO(new Date()),
108109
typescript_version: process.version,
109110
otf_api_ts_version: '1.0.0', // Current version
110111
tests: {},

src/api/bookings.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { components } from '../generated/types';
2+
import { formatDateToPythonISO, formatDateForPythonParity, safeDateFormat } from '../utils/datetime';
23

34
type BookingV2Base = components['schemas']['BookingV2'];
45
type BookingStatus = components['schemas']['BookingStatus'];
@@ -102,11 +103,6 @@ export class BookingsApi {
102103
* Python: "2025-07-29T12:00:00+00:00"
103104
* JavaScript default: "2025-07-29T12:00:00.000Z"
104105
*/
105-
private formatDateToPythonISO(date: Date): string {
106-
// Get ISO string and convert Z format to +00:00 format
107-
// Remove milliseconds (.000) and replace Z with +00:00
108-
return date.toISOString().replace(/\.\d{3}Z$/, '+00:00');
109-
}
110106

111107
/**
112108
* Formats coach name consistently, handling undefined/null fields
@@ -168,8 +164,8 @@ export class BookingsApi {
168164
apiType: 'performance',
169165
path: '/v1/bookings/me',
170166
params: {
171-
'starts_after': startDate.toISOString(),
172-
'ends_before': endDate.toISOString(),
167+
'starts_after': formatDateToPythonISO(startDate),
168+
'ends_before': formatDateToPythonISO(endDate),
173169
'include_canceled': (!excludeCancelled).toString(),
174170
'expand': 'false',
175171
},
@@ -235,7 +231,7 @@ export class BookingsApi {
235231
} : null,
236232
class_id: data.class?.id || null,
237233
class_type: data.class?.type || {}, // Empty object to match Python behavior
238-
starts_at_utc: data.class?.starts_at ? this.formatDateToPythonISO(new Date(data.class.starts_at)) : null,
234+
starts_at_utc: data.class?.starts_at ? formatDateToPythonISO(new Date(data.class.starts_at)) : null,
239235
},
240236

241237
// Workout - should now be included with correct API parameters

src/api/members.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { components } from '../generated/types';
33
type MemberDetail = components['schemas']['MemberDetail'];
44
import { OtfHttpClient } from '../client/http-client';
55
import { API_ENDPOINTS } from '../types/config';
6+
import { safeDateFormat } from '../utils/datetime';
67

78
/**
89
* API for member profile and membership operations
@@ -113,9 +114,9 @@ export class MembersApi {
113114
total_ot_live_classes_attended: data.memberClassSummary.totalOTLiveClassesAttended || null,
114115
total_classes_used_hrm: data.memberClassSummary.totalClassesUsedHRM || null,
115116
total_studios_visited: data.memberClassSummary.totalStudiosVisited || null,
116-
first_visit_date: data.memberClassSummary.firstVisitDate || null,
117-
last_class_visited_date: data.memberClassSummary.lastClassVisitedDate || null,
118-
last_class_booked_date: data.memberClassSummary.lastClassBookedDate || null,
117+
first_visit_date: safeDateFormat(data.memberClassSummary.firstVisitDate, 'first_visit_date'),
118+
last_class_visited_date: safeDateFormat(data.memberClassSummary.lastClassVisitedDate, 'last_class_visited_date'),
119+
last_class_booked_date: safeDateFormat(data.memberClassSummary.lastClassBookedDate, 'last_class_booked_date'),
119120
last_class_studio_visited: null,
120121
} : null,
121122

src/api/workouts.ts

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { OtfHttpClient } from '../client/http-client';
22
import { StatsTime, EquipmentType, ChallengeCategory } from '../types/workout-enums';
33
import { components } from '../generated/types';
44
import { BodyCompositionData } from '../models/body-composition';
5+
import { formatDateToPythonISO, formatDateForPythonParity, safeDateFormat } from '../utils/datetime';
56

67
type Workout = components['schemas']['Workout'];
78
type BookingV2 = components['schemas']['BookingV2'];
@@ -171,8 +172,8 @@ export class WorkoutsApi {
171172
private filterEquipmentData(equipmentData: any): any {
172173
if (!equipmentData) return equipmentData;
173174

174-
// Create a deep copy to avoid mutation
175-
const filtered = JSON.parse(JSON.stringify(equipmentData));
175+
// Create a deep copy to avoid mutation without converting dates
176+
const filtered = this.deepCopyPreservingDates(equipmentData);
176177

177178
// Remove max_power to match Python structure
178179
delete filtered.max_power;
@@ -185,14 +186,18 @@ export class WorkoutsApi {
185186
}
186187

187188
/**
188-
* Formats date to match Python's ISO format exactly
189-
* Python: "2025-07-29T12:00:00+00:00"
190-
* JavaScript default: "2025-07-29T12:00:00.000Z"
189+
* Deep copy that preserves date strings without converting them back to .000Z format
191190
*/
192-
private formatDateToPythonISO(date: Date): string {
193-
// Get ISO string and convert Z format to +00:00 format
194-
// Remove milliseconds (.000) and replace Z with +00:00
195-
return date.toISOString().replace(/\.\d{3}Z$/, '+00:00');
191+
private deepCopyPreservingDates(obj: any): any {
192+
if (obj === null || typeof obj !== 'object') return obj;
193+
if (obj instanceof Date) return obj; // Keep Date objects as is
194+
if (Array.isArray(obj)) return obj.map(item => this.deepCopyPreservingDates(item));
195+
196+
const result: any = {};
197+
for (const [key, value] of Object.entries(obj)) {
198+
result[key] = this.deepCopyPreservingDates(value);
199+
}
200+
return result;
196201
}
197202

198203
/**
@@ -483,7 +488,7 @@ export class WorkoutsApi {
483488
// Python returns: { class_history_uuid, class_start_time, max_hr, member_uuid, performance_summary_id, window_size, zones, telemetry: [...] }
484489
return {
485490
class_history_uuid: response.classHistoryUuid || performanceSummaryId,
486-
class_start_time: response.classStartTime || null,
491+
class_start_time: response.classStartTime ? formatDateToPythonISO(new Date(response.classStartTime)) : null,
487492
max_hr: response.maxHr || 0,
488493
member_uuid: response.memberUuid || this.memberUuid,
489494
performance_summary_id: response.classHistoryUuid || performanceSummaryId,
@@ -506,8 +511,8 @@ export class WorkoutsApi {
506511
apiType: 'default',
507512
path: `/member/members/${this.memberUuid}/out-of-studio-workout`,
508513
params: {
509-
startDate: startDate.toISOString(),
510-
endDate: endDate.toISOString(),
514+
startDate: formatDateToPythonISO(startDate),
515+
endDate: formatDateToPythonISO(endDate),
511516
},
512517
});
513518

@@ -893,7 +898,7 @@ export class WorkoutsApi {
893898

894899
const enhancedItem = { ...item };
895900
const absoluteTime = new Date(classStart.getTime() + (item.relative_timestamp * 1000)); // Convert seconds to milliseconds
896-
enhancedItem.timestamp = this.formatDateToPythonISO(absoluteTime);
901+
enhancedItem.timestamp = formatDateToPythonISO(absoluteTime);
897902

898903
return enhancedItem;
899904
});
@@ -904,7 +909,7 @@ export class WorkoutsApi {
904909
// Return the complete telemetry object structure to match Python
905910
return {
906911
class_history_uuid: telemetry.classHistoryUuid,
907-
class_start_time: telemetry.classStartTime,
912+
class_start_time: telemetry.classStartTime ? formatDateToPythonISO(new Date(telemetry.classStartTime)) : null,
908913
max_hr: telemetry.maxHr,
909914
member_uuid: telemetry.memberUuid,
910915
performance_summary_id: telemetry.classHistoryUuid, // Same as class_history_uuid
@@ -930,18 +935,18 @@ export class WorkoutsApi {
930935
// Match Python logic exactly
931936
switch (classType) {
932937
case 'ORANGE_60':
933-
return this.formatDateToPythonISO(new Date(start.getTime() + (60 * 60 * 1000))); // 60 minutes
938+
return formatDateToPythonISO(new Date(start.getTime() + (60 * 60 * 1000))); // 60 minutes
934939
case 'ORANGE_90':
935-
return this.formatDateToPythonISO(new Date(start.getTime() + (90 * 60 * 1000))); // 90 minutes
940+
return formatDateToPythonISO(new Date(start.getTime() + (90 * 60 * 1000))); // 90 minutes
936941
case 'STRENGTH_50':
937942
case 'TREAD_50':
938-
return this.formatDateToPythonISO(new Date(start.getTime() + (50 * 60 * 1000))); // 50 minutes
943+
return formatDateToPythonISO(new Date(start.getTime() + (50 * 60 * 1000))); // 50 minutes
939944
case 'OTHER':
940945
console.warn(`Class type ${classType} does not have defined length, returning start time plus 60 minutes`);
941-
return this.formatDateToPythonISO(new Date(start.getTime() + (60 * 60 * 1000))); // Default 60 minutes
946+
return formatDateToPythonISO(new Date(start.getTime() + (60 * 60 * 1000))); // Default 60 minutes
942947
default:
943948
console.warn(`Class type ${classType} is not recognized, returning start time plus 60 minutes`);
944-
return this.formatDateToPythonISO(new Date(start.getTime() + (60 * 60 * 1000))); // Default 60 minutes
949+
return formatDateToPythonISO(new Date(start.getTime() + (60 * 60 * 1000))); // Default 60 minutes
945950
}
946951
}
947952

src/models/body-composition.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
* Matches Python implementation in body_composition_list.py exactly
1111
*/
1212

13+
import { formatDateToLocal } from '../utils/datetime';
14+
1315
// Constants matching Python defaults
1416
export const DEFAULT_WEIGHT_DIVIDERS = [55.0, 70.0, 85.0, 100.0, 115.0, 130.0, 145.0, 160.0, 175.0, 190.0, 205.0];
1517
export const DEFAULT_SKELETAL_MUSCLE_MASS_DIVIDERS = [70.0, 80.0, 90.0, 100.0, 110.0, 120.0, 130.0, 140.0, 150.0, 160.0, 170.0];
@@ -50,17 +52,6 @@ export function convertKgToLbs(weightKg: number): number {
5052
* Python: "2024-11-16T07:13:35" (no timezone, no milliseconds)
5153
* JavaScript default: "2024-11-16T15:13:35.000Z" (UTC with milliseconds)
5254
*/
53-
function formatDateTimeToLocal(date: Date): string {
54-
// Format as local time without timezone suffix to match Python
55-
const year = date.getFullYear();
56-
const month = String(date.getMonth() + 1).padStart(2, '0');
57-
const day = String(date.getDate()).padStart(2, '0');
58-
const hours = String(date.getHours()).padStart(2, '0');
59-
const minutes = String(date.getMinutes()).padStart(2, '0');
60-
const seconds = String(date.getSeconds()).padStart(2, '0');
61-
62-
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
63-
}
6455

6556
/**
6657
* Calculate body fat mass control value to match Python implementation
@@ -344,7 +335,7 @@ export class BodyCompositionData {
344335
this.height = data.height;
345336
this.gender = data.gender;
346337
this.age = parseFloat(data.age) || 0; // Convert string to number to match Python
347-
this.scan_datetime = formatDateTimeToLocal(new Date(data.testDatetime));
338+
this.scan_datetime = formatDateToLocal(new Date(data.testDatetime));
348339
this.provided_weight = data.weight;
349340

350341
// Apply critical business logic: convert kg to lbs

src/utils/datetime.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* Centralized datetime formatting utilities to ensure 100% Python parity
3+
*
4+
* Python uses two main datetime formats:
5+
* 1. WITH TIMEZONE: 2025-07-29T12:00:26+00:00 (ISO with +00:00 format)
6+
* 2. WITHOUT TIMEZONE: 2024-11-16T07:13:35 (local format, no timezone)
7+
*/
8+
9+
/**
10+
* Formats date to match Python's ISO format exactly
11+
* Python: "2025-07-29T12:00:00+00:00"
12+
* JavaScript default: "2025-07-29T12:00:00.000Z"
13+
*
14+
* Use for: class_start_time, starts_at_utc, timestamp, created_at, updated_at
15+
*/
16+
export function formatDateToPythonISO(date: Date): string {
17+
// Get ISO string and convert Z format to +00:00 format
18+
// Remove milliseconds (.000) and replace Z with +00:00
19+
return date.toISOString().replace(/\.\d{3}Z$/, '+00:00');
20+
}
21+
22+
/**
23+
* Formats date to Python's local format (no timezone)
24+
* Python: "2024-11-16T07:13:35"
25+
*
26+
* Use for: scan_datetime, starts_at (local), open_date, end, start
27+
*/
28+
export function formatDateToLocal(date: Date): string {
29+
const year = date.getFullYear();
30+
const month = String(date.getMonth() + 1).padStart(2, '0');
31+
const day = String(date.getDate()).padStart(2, '0');
32+
const hours = String(date.getHours()).padStart(2, '0');
33+
const minutes = String(date.getMinutes()).padStart(2, '0');
34+
const seconds = String(date.getSeconds()).padStart(2, '0');
35+
36+
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
37+
}
38+
39+
/**
40+
* Auto-detects and formats date based on field name to match Python patterns
41+
*
42+
* @param date Date object to format
43+
* @param fieldName Field name to determine format (e.g., 'scan_datetime', 'class_start_time')
44+
* @returns Formatted date string matching Python format
45+
*/
46+
export function formatDateForPythonParity(date: Date, fieldName: string): string {
47+
// Fields that use local format (no timezone)
48+
const localFormatFields = [
49+
'scan_datetime',
50+
'starts_at', // local class start time
51+
'open_date',
52+
're_open_date',
53+
'end',
54+
'start'
55+
];
56+
57+
if (localFormatFields.includes(fieldName)) {
58+
return formatDateToLocal(date);
59+
}
60+
61+
// Default to ISO format with +00:00 timezone for all other datetime fields
62+
return formatDateToPythonISO(date);
63+
}
64+
65+
/**
66+
* Safely parses date string/object and formats for Python parity
67+
*
68+
* @param dateValue Date string, Date object, or null/undefined
69+
* @param fieldName Field name for format detection
70+
* @returns Formatted date string or null
71+
*/
72+
export function safeDateFormat(dateValue: string | Date | null | undefined, fieldName: string): string | null {
73+
if (!dateValue) return null;
74+
75+
try {
76+
const date = typeof dateValue === 'string' ? new Date(dateValue) : dateValue;
77+
78+
if (isNaN(date.getTime())) {
79+
return null;
80+
}
81+
82+
return formatDateForPythonParity(date, fieldName);
83+
} catch (error) {
84+
console.warn(`Failed to format date for field ${fieldName}:`, error);
85+
return null;
86+
}
87+
}
88+
89+
/**
90+
* Legacy function maintained for backward compatibility
91+
* @deprecated Use formatDateToPythonISO instead
92+
*/
93+
export function formatDateTimeToLocal(date: Date): string {
94+
return formatDateToLocal(date);
95+
}

test/api/bookings.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ describe('BookingsApi', () => {
2121
checked_in: true,
2222
canceled: false,
2323
ratable: true,
24+
status: 'Booked',
2425
class: {
2526
classUuid: 'test-class-uuid',
2627
name: 'Orange 60 3G',
@@ -77,7 +78,7 @@ describe('BookingsApi', () => {
7778
mbo_studio_id: null,
7879
},
7980
class_id: null,
80-
class_type: null,
81+
class_type: {},
8182
starts_at_utc: null,
8283
},
8384
workout: {
@@ -96,6 +97,7 @@ describe('BookingsApi', () => {
9697
mbo_paying_unique_id: null,
9798
created_at: null,
9899
updated_at: null,
100+
status: 'Booked',
99101
});
100102

101103
expect(mockClient.workoutRequest).toHaveBeenCalledWith({

0 commit comments

Comments
 (0)