Skip to content

Commit 1066fa5

Browse files
authored
Merge pull request #1083 from apedley/timezones-continued
2 parents 358816c + a8cd30d commit 1066fa5

12 files changed

Lines changed: 215 additions & 46 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ pnpm test
145145
### Date, Day Strings, and Timezones
146146

147147
- Prefer the shared helpers in `shared/src/utils/timezone.ts`, exported through `@workspace/shared`, for day-string and timezone-aware date logic.
148-
- Use `isDayString`, `addDays`, `compareDays`, `dayToPickerDate`, and `pickerDateToDay` for `YYYY-MM-DD` calendar-day strings.
148+
- Use `isDayString`, `addDays`, `compareDays`, `dayToPickerDate`, and `localDateToDay` for `YYYY-MM-DD` calendar-day strings.
149149
- Use `todayInZone`, `instantToDay`, `instantHourMinute`, `dayToUtcRange`, and `dayRangeToUtcRange` when a user's timezone matters.
150150
- On the server, load the user's timezone with `SparkyFitnessServer/utils/timezoneLoader.js` before deriving "today", bucketing timestamps by day, or building day-based query ranges.
151151
- Avoid ad hoc UTC date extraction such as `toISOString().split('T')[0]` for user-facing or business-logic dates. That pattern silently shifts dates near timezone boundaries and is not a substitute for timezone-aware day handling.

SparkyFitnessMobile/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ Monorepo package at `../shared/` providing Zod schemas, TypeScript types, consta
126126
- **Exercise/workout types**: `ExerciseSessionResponse` (discriminated union: `IndividualSessionResponse | PresetSessionResponse`), `ExerciseHistoryResponse`, `CreatePresetSessionRequest`, `ExerciseEntryResponse`, `ExerciseEntrySetResponse`, `ActivityDetailResponse`, `Pagination`
127127
- **API schemas**: `dailySummaryResponseSchema`/`DailySummaryResponse` (aggregates goals + food entries + exercise sessions + water intake), `dailyGoalsResponseSchema`, `foodEntryResponseSchema`, `exerciseSessionResponseSchema`
128128
- **Constants**: `MEASUREMENT_PRECISION`/`getPrecision()` for decimal formatting, `CALORIE_CALCULATION_CONSTANTS`/`ACTIVITY_MULTIPLIERS` for step/calorie math
129-
- **Timezone utilities** (`shared/src/utils/timezone.ts`): `isDayString`, `addDays`, `compareDays`, `dayToPickerDate`, `pickerDateToDay` for day-string operations; `isValidTimeZone`, `todayInZone`, `instantToDay`, `userHourMinute`, `instantHourMinute`, `dayToUtcRange`, `dayRangeToUtcRange` for timezone-aware conversions
129+
- **Timezone utilities** (`shared/src/utils/timezone.ts`): `isDayString`, `addDays`, `compareDays`, `dayToPickerDate`, `localDateToDay` for day-string operations; `isValidTimeZone`, `todayInZone`, `instantToDay`, `userHourMinute`, `instantHourMinute`, `dayToUtcRange`, `dayRangeToUtcRange` for timezone-aware conversions
130130

131131
### Workout & Exercise Architecture
132132

SparkyFitnessServer/integrations/fitbit/fitbitDataProcessor.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const exerciseRepository = require('../../models/exercise');
66
const activityDetailsRepository = require('../../models/activityDetailsRepository');
77
const sleepRepository = require('../../models/sleepRepository');
88
const { log } = require('../../config/logging');
9-
const { todayInZone } = require('@workspace/shared');
9+
const { localDateToDay, todayInZone } = require('@workspace/shared');
1010

1111
// Conversion factors for en-US to Metric
1212
const LBS_TO_KG = 0.453592;
@@ -236,7 +236,6 @@ async function processFitbitTemperature(
236236
let tempVariation = entry.value.nightlyRelative;
237237

238238
if (tempVariation !== undefined) {
239-
240239
await upsertCustomMeasurementLogic(userId, createdByUserId, {
241240
categoryName: 'Skin Temperature Variation',
242241
value: tempVariation,
@@ -393,7 +392,6 @@ async function processFitbitCoreTemperature(
393392
let temp = entry.value;
394393

395394
if (temp !== undefined) {
396-
397395
await upsertCustomMeasurementLogic(userId, createdByUserId, {
398396
categoryName: 'Core Temperature',
399397
value: temp,
@@ -602,7 +600,7 @@ async function processFitbitActivities(
602600
}
603601

604602
let distanceKm = activity.distance;
605-
603+
606604
const entryData = {
607605
exercise_id: exercise.id,
608606
entry_date: entryDate,
@@ -663,7 +661,7 @@ async function processFitbitActivities(
663661
let dateKey = m.entry_date;
664662
// Handle different possible types for entry_date (Date object or string)
665663
if (dateKey instanceof Date) {
666-
dateKey = dateKey.toISOString().split('T')[0];
664+
dateKey = localDateToDay(dateKey);
667665
} else if (typeof dateKey === 'string' && dateKey.includes('T')) {
668666
dateKey = dateKey.split('T')[0];
669667
}

SparkyFitnessServer/models/exerciseTemplate.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ const format = require('pg-format');
33
const { log } = require('../config/logging');
44
const workoutPresetRepository = require('./workoutPresetRepository');
55
const { getExerciseById } = require('./exercise');
6-
const { addDays, compareDays, dayOfWeek } = require('@workspace/shared');
6+
const {
7+
addDays,
8+
compareDays,
9+
dayOfWeek,
10+
localDateToDay,
11+
} = require('@workspace/shared');
712

813
async function createExerciseEntriesFromTemplate(templateId, userId, today) {
914
log(
@@ -66,12 +71,12 @@ async function createExerciseEntriesFromTemplate(templateId, userId, today) {
6671
const startDay =
6772
typeof template.start_date === 'string'
6873
? template.start_date.slice(0, 10)
69-
: template.start_date.toISOString().slice(0, 10);
74+
: localDateToDay(template.start_date);
7075
// If end_date is not provided, default to one year from start_date
7176
const endDay = template.end_date
7277
? typeof template.end_date === 'string'
7378
? template.end_date.slice(0, 10)
74-
: template.end_date.toISOString().slice(0, 10)
79+
: localDateToDay(template.end_date)
7580
: addDays(startDay, 365);
7681

7782
log(

SparkyFitnessServer/models/foodTemplate.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ const { log } = require('../config/logging');
33
const format = require('pg-format');
44
const foodEntryDb = require('./foodEntry');
55
const foodEntryMealRepository = require('./foodEntryMealRepository');
6-
const { addDays, compareDays, dayOfWeek } = require('@workspace/shared');
6+
const {
7+
addDays,
8+
compareDays,
9+
dayOfWeek,
10+
localDateToDay,
11+
} = require('@workspace/shared');
712

813
async function deleteFoodEntriesByMealPlanId(mealPlanId, userId) {
914
const client = await getClient(userId); // User-specific operation
@@ -128,12 +133,12 @@ async function createFoodEntriesFromTemplate(templateId, userId, today) {
128133
const startDay =
129134
typeof start_date === 'string'
130135
? start_date.slice(0, 10)
131-
: start_date.toISOString().slice(0, 10);
136+
: localDateToDay(start_date);
132137
// If end_date is not provided, default to one year from start_date
133138
const endDay = end_date
134139
? typeof end_date === 'string'
135140
? end_date.slice(0, 10)
136-
: end_date.toISOString().slice(0, 10)
141+
: localDateToDay(end_date)
137142
: addDays(startDay, 365);
138143

139144
// Start from today if template start_date is in the past
@@ -224,7 +229,7 @@ async function createFoodEntriesFromTemplate(templateId, userId, today) {
224229
const dateStr =
225230
typeof entry.entry_date === 'string'
226231
? entry.entry_date.slice(0, 10)
227-
: entry.entry_date.toISOString().slice(0, 10);
232+
: localDateToDay(entry.entry_date);
228233
const key = `${entry.food_id || entry.meal_id}-${entry.meal_type_id}-${dateStr}-${entry.variant_id}`;
229234
existingFoodEntries.add(key);
230235
});

SparkyFitnessServer/services/exerciseEntryHistoryService.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
localDateToDay,
23
presetSessionResponseSchema,
34
type PresetSessionResponse,
45
type ActivityDetailResponse,
@@ -14,7 +15,7 @@ const { log } = require('../config/logging');
1415
/** Convert a pg date value to a YYYY-MM-DD string, or return null. */
1516
function _dateToString(value: unknown): string | null {
1617
if (value == null) return null;
17-
if (value instanceof Date) return value.toISOString().split('T')[0];
18+
if (value instanceof Date) return localDateToDay(value);
1819
return String(value);
1920
}
2021

SparkyFitnessServer/services/reportService.js

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -432,9 +432,8 @@ function calculateMuscleGroupRecovery(exerciseEntries) {
432432
? JSON.parse(entry.exercises.primary_muscles || '[]')
433433
: [];
434434
muscles.forEach((muscle) => {
435-
const entryDate = new Date(entry.entry_date);
436-
if (!recoveryData[muscle] || entryDate > new Date(recoveryData[muscle])) {
437-
recoveryData[muscle] = entryDate.toISOString().split('T')[0];
435+
if (!recoveryData[muscle] || entry.entry_date > recoveryData[muscle]) {
436+
recoveryData[muscle] = entry.entry_date;
438437
}
439438
});
440439
});
@@ -501,7 +500,7 @@ function calculatePrProgression(exerciseEntries) {
501500
reps > lastPr.maxReps
502501
) {
503502
progression[entry.exercise_name].push({
504-
date: new Date(entry.entry_date).toISOString().split('T')[0],
503+
date: entry.entry_date,
505504
oneRM: oneRM,
506505
maxWeight: weight,
507506
maxReps: reps,
@@ -593,7 +592,7 @@ async function getExerciseDashboardData(
593592
const muscleGroupVolume = {}; // Stores total volume per muscle group
594593

595594
exerciseEntries.forEach((entry) => {
596-
totalWorkouts.add(new Date(entry.entry_date).toISOString().split('T')[0]); // Add unique dates
595+
totalWorkouts.add(entry.entry_date); // Add unique dates
597596

598597
if (entry.sets && entry.sets.length > 0) {
599598
entry.sets.forEach((set) => {
@@ -613,7 +612,7 @@ async function getExerciseDashboardData(
613612
) {
614613
prData[entry.exercise_name] = {
615614
oneRM,
616-
date: new Date(entry.entry_date).toISOString().split('T')[0],
615+
date: entry.entry_date,
617616
weight,
618617
reps,
619618
};
@@ -632,7 +631,7 @@ async function getExerciseDashboardData(
632631
bestSetRepRange[entry.exercise_name][repRange] = {
633632
weight,
634633
reps,
635-
date: new Date(entry.entry_date).toISOString().split('T')[0],
634+
date: entry.entry_date,
636635
};
637636
}
638637
}

SparkyFitnessServer/services/sleepScienceService.js

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const { loadUserTimezone } = require('../utils/timezoneLoader');
44
const {
55
instantHourMinute,
66
dayOfWeek,
7+
localDateToDay,
78
userHourMinute,
89
} = require('@workspace/shared');
910

@@ -184,9 +185,7 @@ function classifyDaysAutomatically(history, timezone = 'UTC') {
184185
if (wakeHour === null) continue;
185186

186187
const dateStr =
187-
typeof entry.date === 'string'
188-
? entry.date
189-
: entry.date.toISOString().slice(0, 10);
188+
typeof entry.date === 'string' ? entry.date : localDateToDay(entry.date);
190189
const dow = dayOfWeek(dateStr);
191190

192191
if (!dayBuckets.has(dow)) dayBuckets.set(dow, []);
@@ -251,9 +250,7 @@ async function calculateBaseline(userId, windowDays = 90, timezone = 'UTC') {
251250
if (tst === null || tst < 3 || tst > 14) continue;
252251

253252
const dateStr =
254-
typeof entry.date === 'string'
255-
? entry.date
256-
: entry.date.toISOString().slice(0, 10);
253+
typeof entry.date === 'string' ? entry.date : localDateToDay(entry.date);
257254
const dow = dayOfWeek(dateStr);
258255
const dayType = dayClassification.get(dow) || 'workday';
259256

@@ -360,9 +357,7 @@ async function calculateBaseline(userId, windowDays = 90, timezone = 'UTC') {
360357

361358
// Determine data range
362359
const dates = history
363-
.map((e) =>
364-
typeof e.date === 'string' ? e.date : e.date.toISOString().slice(0, 10)
365-
)
360+
.map((e) => (typeof e.date === 'string' ? e.date : localDateToDay(e.date)))
366361
.sort();
367362
const dataStartDate = dates[0] || null;
368363
const dataEndDate = dates[dates.length - 1] || null;
@@ -446,9 +441,7 @@ function getDayOfWeekStats(history, classification, timezone = 'UTC') {
446441
if (wakeHour === null) continue;
447442

448443
const dateStr =
449-
typeof entry.date === 'string'
450-
? entry.date
451-
: entry.date.toISOString().slice(0, 10);
444+
typeof entry.date === 'string' ? entry.date : localDateToDay(entry.date);
452445
const dow = dayOfWeek(dateStr);
453446

454447
if (!buckets.has(dow)) buckets.set(dow, []);
@@ -862,9 +855,7 @@ async function checkDataSufficiency(userId) {
862855
let freedayCount = 0;
863856
for (const entry of entriesWithTimestamps) {
864857
const dateStr =
865-
typeof entry.date === 'string'
866-
? entry.date
867-
: entry.date.toISOString().slice(0, 10);
858+
typeof entry.date === 'string' ? entry.date : localDateToDay(entry.date);
868859
const dow = dayOfWeek(dateStr);
869860
if (dow === 0 || dow === 6) {
870861
freedayCount++;

0 commit comments

Comments
 (0)