diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd0c4eb3..1e65cbe5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,13 @@ permissions: jobs: build: runs-on: ubuntu-latest + strategy: + matrix: + # Run the full suite behind UTC, at UTC and ahead of UTC so that + # timezone-dependent date handling bugs can't slip through + timezone: [ 'America/Toronto', 'UTC', 'Asia/Kolkata' ] + name: + CI job (timezone: ${{ matrix.timezone }}) steps: - uses: actions/checkout@v7 @@ -36,8 +43,12 @@ jobs: - name: Run tests with coverage run: npm run test:coverage + env: + TZ: ${{ matrix.timezone }} - name: Coveralls + # coverage is identical in all timezones, upload it only once + if: matrix.timezone == 'UTC' uses: coverallsapp/github-action@master with: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/src/components/Measurements/api/measurements.test.ts b/src/components/Measurements/api/measurements.test.ts index d68606e2..226b4f06 100644 --- a/src/components/Measurements/api/measurements.test.ts +++ b/src/components/Measurements/api/measurements.test.ts @@ -1,6 +1,3 @@ -import axios from "axios"; -import { MeasurementCategory } from "@/components/Measurements/models/Category"; -import { MeasurementEntry } from "@/components/Measurements/models/Entry"; import { addMeasurementCategory, addMeasurementEntry, @@ -11,6 +8,9 @@ import { getMeasurementCategories, getMeasurementCategory, } from "@/components/Measurements/api/measurements"; +import { MeasurementCategory } from "@/components/Measurements/models/Category"; +import { MeasurementEntry } from "@/components/Measurements/models/Entry"; +import axios from "axios"; import type { Mock } from 'vitest'; vi.mock("axios"); @@ -31,7 +31,7 @@ describe('measurement service tests', () => { "id": ENTRY_UUID, "category": CATEGORY_UUID, "value": 80, - "date": "2021-01-01", + "date": "2021-01-01T08:00:00+01:00", "notes": "" } ] @@ -95,7 +95,7 @@ describe('measurement service tests', () => { expect(result).toStrictEqual([ new MeasurementCategory(CATEGORY_UUID, "Weight", "kg", [ - new MeasurementEntry(ENTRY_UUID, CATEGORY_UUID, new Date("2021-01-01"), 80, "") + new MeasurementEntry(ENTRY_UUID, CATEGORY_UUID, new Date("2021-01-01T08:00:00+01:00"), 80, "") ]) ]); }); @@ -115,7 +115,7 @@ describe('measurement service tests', () => { expect(result).toStrictEqual( new MeasurementCategory(CATEGORY_UUID, "Weight", "kg", [ - new MeasurementEntry(ENTRY_UUID, CATEGORY_UUID, new Date("2021-01-01"), 80, "") + new MeasurementEntry(ENTRY_UUID, CATEGORY_UUID, new Date("2021-01-01T08:00:00+01:00"), 80, "") ]) ); }); @@ -162,7 +162,7 @@ describe('measurement service tests', () => { test('addMeasurementEntry POSTs the entry with serialized date', async () => { (axios.post as Mock).mockResolvedValue({ - data: { id: ENTRY_UUID_2, category: CATEGORY_UUID, value: 80.5, date: "2024-08-01", notes: "" }, + data: { id: ENTRY_UUID_2, category: CATEGORY_UUID, value: 80.5, date: "2024-08-01T12:34:00Z", notes: "" }, }); const result = await addMeasurementEntry({ @@ -180,15 +180,21 @@ describe('measurement service tests', () => { value: 80.5, notes: "", }); - // Date is YYYY-MM-DD - expect(body.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + // the full timestamp is sent, the server field is a datetime + expect(body.date).toBe("2024-08-01T12:34:00.000Z"); expect(result).toBeInstanceOf(MeasurementEntry); expect(result.id).toBe(ENTRY_UUID_2); }); test('editMeasurementEntry PATCHes /measurement// with date/value/notes only', async () => { (axios.patch as Mock).mockResolvedValue({ - data: { id: ENTRY_UUID_2, category: CATEGORY_UUID, value: 81, date: "2024-08-02", notes: "edited" }, + data: { + id: ENTRY_UUID_2, + category: CATEGORY_UUID, + value: 81, + date: "2024-08-02T00:00:00Z", + notes: "edited" + }, }); const result = await editMeasurementEntry({ @@ -205,7 +211,7 @@ describe('measurement service tests', () => { // Note: 'category' is NOT sent on edit (categoryId is part of the params but ignored in body) expect(body).not.toHaveProperty("category"); expect(body).toMatchObject({ value: 81, notes: "edited" }); - expect(body.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(body.date).toBe("2024-08-02T00:00:00.000Z"); expect(result.value).toBe(81); }); diff --git a/src/components/Measurements/api/measurements.ts b/src/components/Measurements/api/measurements.ts index b39dd089..2f2b421a 100644 --- a/src/components/Measurements/api/measurements.ts +++ b/src/components/Measurements/api/measurements.ts @@ -3,7 +3,6 @@ import { MeasurementCategory } from "@/components/Measurements/models/Category"; import { MeasurementEntry } from "@/components/Measurements/models/Entry"; import { ApiMeasurementCategoryType } from '@/types'; import { API_MAX_PAGE_SIZE } from "@/core/lib/consts"; -import { dateToYYYYMMDD } from "@/core/lib/date"; import { fetchPaginated } from '@/core/lib/requests'; import { makeHeader, makeUrl } from "@/core/lib/url"; @@ -145,7 +144,8 @@ export const editMeasurementEntry = async (data: editMeasurementParams): Promise const response = await axios.patch( makeUrl(API_MEASUREMENTS_ENTRY_PATH, { id: data.id }), { - date: dateToYYYYMMDD(data.date), + // the server field is a datetime, send the full timestamp + date: data.date.toISOString(), value: data.value, notes: data.notes }, @@ -168,7 +168,8 @@ export const addMeasurementEntry = async (data: AddMeasurementParams): Promise { return new MeasurementEntry( item.id, item.category, + // full ISO datetime from the server, parsing is timezone-safe new Date(item.date), item.value, item.notes diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx index bc45f194..5c7d53a5 100644 --- a/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx +++ b/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx @@ -44,11 +44,12 @@ describe("Test the MeasurementCategoryDetail component", () => { expect(screen.getByText('Biceps')).toBeInTheDocument(); expect(screen.getByRole('gridcell', { name: /10cm/i })).toBeInTheDocument(); - expect(screen.getAllByText(/Feb 1, 2023/i).length).toBeGreaterThanOrEqual(1); + // the entries now show date and time + expect(screen.getAllByText(/2\/1\/2023, 8:00 AM/i).length).toBeGreaterThanOrEqual(1); expect(screen.getByText('test note')).toBeInTheDocument(); expect(screen.getByRole('gridcell', { name: /20cm/i })).toBeInTheDocument(); - expect(screen.getByText(/Feb 2, 2023/i)).toBeInTheDocument(); + expect(screen.getByText(/2\/2\/2023, 7:45 AM/i)).toBeInTheDocument(); expect(screen.getByText('important note')).toBeInTheDocument(); }); }); diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx index 8de749a2..bbbc1230 100644 --- a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx +++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx @@ -109,14 +109,14 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) { field: 'date', headerName: t('date'), - type: 'date', - width: 120, + type: 'dateTime', + width: 160, editable: true, valueFormatter: (value?: Date) => { if (value == null) { return ''; } - return luxonDateTimeToLocale(DateTime.fromJSDate(value)); + return luxonDateTimeToLocale(DateTime.fromJSDate(value), undefined, DateTime.DATETIME_SHORT); }, }, { diff --git a/src/components/Measurements/widgets/EntryForm.tsx b/src/components/Measurements/widgets/EntryForm.tsx index 20cafcaa..8a38be07 100644 --- a/src/components/Measurements/widgets/EntryForm.tsx +++ b/src/components/Measurements/widgets/EntryForm.tsx @@ -9,14 +9,11 @@ import { useMeasurementsQuery } from "@/components/Measurements/queries"; import { Form, Formik } from "formik"; -import { DateTime, Settings } from "luxon"; +import { DateTime } from "luxon"; import React from 'react'; import { useTranslation } from "react-i18next"; -import { TIMEZONE } from "@/core/lib/consts"; import * as yup from 'yup'; -Settings.defaultZone = TIMEZONE; - interface EntryFormProps { entry?: MeasurementEntry, closeFn?: () => void, diff --git a/src/components/Nutrition/api/nutritionalPlan.test.ts b/src/components/Nutrition/api/nutritionalPlan.test.ts index 12f8e6d3..6da35cb8 100644 --- a/src/components/Nutrition/api/nutritionalPlan.test.ts +++ b/src/components/Nutrition/api/nutritionalPlan.test.ts @@ -18,6 +18,7 @@ import { } from "@/tests/nutritionTestdata"; import axios from "axios"; import type { Mock } from 'vitest'; +import { yyyymmddToDate } from "@/core/lib/date"; vi.mock("axios"); vi.mock("@/components/Nutrition/api/meal"); @@ -83,23 +84,23 @@ describe("Nutritional plan service tests", () => { expect(result).toStrictEqual([ new NutritionalPlan({ id: PLAN_UUID_A, - creationDate: new Date('2023-05-26'), - start: new Date('2023-06-01'), - end: new Date('2023-06-30'), + creationDate: yyyymmddToDate('2023-05-26'), + start: yyyymmddToDate('2023-06-01'), + end: yyyymmddToDate('2023-06-30'), description: 'first plan', onlyLogging: true }), new NutritionalPlan({ id: PLAN_UUID_B, - creationDate: new Date('2022-06-01'), + creationDate: yyyymmddToDate('2022-06-01'), description: '', onlyLogging: false }), new NutritionalPlan({ id: PLAN_UUID_C, - creationDate: new Date('2023-08-01'), - start: new Date('2023-08-01'), - end: new Date('2023-08-31'), + creationDate: yyyymmddToDate('2023-08-01'), + start: yyyymmddToDate('2023-08-01'), + end: yyyymmddToDate('2023-08-31'), description: '', onlyLogging: false }), @@ -187,8 +188,8 @@ describe("Nutritional plan service tests", () => { test('addNutritionalPlan POSTs the serialized plan and returns the parsed plan', async () => { const plan = new NutritionalPlan({ description: "summer body", - start: new Date("2024-06-01"), - end: new Date("2024-08-31"), + start: yyyymmddToDate("2024-06-01"), + end: yyyymmddToDate("2024-08-31"), onlyLogging: false, goalEnergy: 2200, }); @@ -217,8 +218,8 @@ describe("Nutritional plan service tests", () => { const plan = new NutritionalPlan({ id: RESPONSE_PLAN_UUID, description: "edited", - start: new Date("2024-06-01"), - end: new Date("2024-08-31"), + start: yyyymmddToDate("2024-06-01"), + end: yyyymmddToDate("2024-08-31"), }); (axios.patch as Mock).mockResolvedValue({ data: { ...responseNutritionalPlanDetail, description: "edited" }, diff --git a/src/components/Nutrition/models/nutritionalPlan.test.ts b/src/components/Nutrition/models/nutritionalPlan.test.ts index 1e3dc6cc..6b73bd43 100644 --- a/src/components/Nutrition/models/nutritionalPlan.test.ts +++ b/src/components/Nutrition/models/nutritionalPlan.test.ts @@ -1,6 +1,7 @@ import { NutritionalPlan, PSEUDO_MEAL_ID } from "@/components/Nutrition/models/nutritionalPlan"; import { TEST_DIARY_ENTRY_3, TEST_DIARY_ENTRY_4 } from "@/tests/nutritionDiaryTestdata"; import { TEST_MEAL_1, TEST_NUTRITIONAL_PLAN_1 } from "@/tests/nutritionTestdata"; +import { yyyymmddToDate } from "@/core/lib/date"; vi.useFakeTimers(); @@ -8,7 +9,8 @@ vi.useFakeTimers(); describe("Test the nutritional plan model", () => { beforeAll(() => { - vi.setSystemTime(new Date('2023-07-01').getTime()); + // local midnight, so that "today" is July 1st in every timezone + vi.setSystemTime(yyyymmddToDate('2023-07-01').getTime()); }); afterAll(() => { @@ -34,7 +36,7 @@ describe("Test the nutritional plan model", () => { test('correctly calculates the nutritional values logged on a specific date', async () => { // Act - const values = TEST_NUTRITIONAL_PLAN_1.loggedNutritionalValuesDate(new Date('2023-07-07')); + const values = TEST_NUTRITIONAL_PLAN_1.loggedNutritionalValuesDate(yyyymmddToDate('2023-07-07')); // Assert expect(values.energy).toBeCloseTo(48, 2); diff --git a/src/components/Nutrition/models/nutritionalPlan.ts b/src/components/Nutrition/models/nutritionalPlan.ts index 263d686c..3288ce53 100644 --- a/src/components/Nutrition/models/nutritionalPlan.ts +++ b/src/components/Nutrition/models/nutritionalPlan.ts @@ -3,7 +3,7 @@ import { DiaryEntry } from "@/components/Nutrition/models/diaryEntry"; import { Meal } from "@/components/Nutrition/models/meal"; import { ApiNutritionalPlanType } from "@/types"; import { Adapter } from "@/core/lib/Adapter"; -import { dateToYYYYMMDD, isSameDay } from "@/core/lib/date"; +import { dateToYYYYMMDD, isSameDay, yyyymmddToDate } from "@/core/lib/date"; /* eslint-disable camelcase */ @@ -142,7 +142,9 @@ export class NutritionalPlan { get groupDiaryEntries(): Map { return this.diaryEntries.reduce((map, entry) => { - const dateKey = entry.datetime.toISOString().split('T')[0]; // Use ISO string format as the key + // Group by the local calendar day: toISOString would group by the UTC + // day, moving e.g. late-night entries to another date + const dateKey = dateToYYYYMMDD(entry.datetime); const entriesForDay = map.get(dateKey) || { entries: [], nutritionalValues: new NutritionalValues() }; entriesForDay.entries.push(entry); entriesForDay.nutritionalValues.add(entry.nutritionalValues); @@ -220,9 +222,9 @@ export class NutritionalPlanAdapter implements Adapter { fromJson(item: ApiNutritionalPlanType) { return new NutritionalPlan({ id: item.id, - creationDate: new Date(item.creation_date), - start: new Date(item.start), - end: item.end !== null ? new Date(item.end) : null, + creationDate: yyyymmddToDate(item.creation_date), + start: yyyymmddToDate(item.start), + end: item.end !== null ? yyyymmddToDate(item.end) : null, description: item.description, onlyLogging: item.only_logging, goalEnergy: item.goal_energy, diff --git a/src/components/Nutrition/queries/plan.ts b/src/components/Nutrition/queries/plan.ts index 632dd994..1940d24e 100644 --- a/src/components/Nutrition/queries/plan.ts +++ b/src/components/Nutrition/queries/plan.ts @@ -9,7 +9,6 @@ import { getNutritionalPlansSparse } from "@/components/Nutrition/api/nutritionalPlan"; import { QueryKey } from "@/core/lib/consts"; -import { dateToYYYYMMDD } from "@/core/lib/date"; export function useFetchNutritionalPlansQuery() { return useQuery({ @@ -40,7 +39,10 @@ export function useFetchNutritionalPlanQuery(planId: string) { export function useFetchNutritionalPlanDateQuery(planId: string | null, dateStr: string, enabled = true) { return useQuery({ queryKey: [QueryKey.NUTRITIONAL_PLAN, planId, dateStr], - queryFn: () => getNutritionalPlanFull(planId, { filtersetQueryLogs: { "datetime__eq": dateToYYYYMMDD(new Date(dateStr)) } }), + // dateStr already is a YYYY-MM-DD string (from the URL), pass it through + // as-is: round-tripping it through new Date() would parse it as UTC + // midnight and shift the day in timezones behind UTC + queryFn: () => getNutritionalPlanFull(planId, { filtersetQueryLogs: { "datetime__eq": dateStr } }), enabled: enabled, }); } diff --git a/src/components/Nutrition/screens/NutritionDiaryOverview.tsx b/src/components/Nutrition/screens/NutritionDiaryOverview.tsx index 66e58e29..819431cc 100644 --- a/src/components/Nutrition/screens/NutritionDiaryOverview.tsx +++ b/src/components/Nutrition/screens/NutritionDiaryOverview.tsx @@ -5,7 +5,7 @@ import { IngredientDetailTable } from "@/components/Nutrition/widgets/Ingredient import { LoggedPlannedNutritionalValuesTable } from "@/components/Nutrition/widgets/LoggedPlannedNutritionalValuesTable"; -import { dateToLocale } from "@/core/lib/date"; +import { dateToLocale, yyyymmddToDate } from "@/core/lib/date"; import { Stack, Typography } from "@mui/material"; import React from "react"; import { useTranslation } from "react-i18next"; @@ -21,7 +21,7 @@ export const NutritionDiaryOverview = () => { return

Please pass a UUID as the nutritional plan id.

; } - const date = new Date(params.date!); + const date = yyyymmddToDate(params.date!); // eslint-disable-next-line react-hooks/rules-of-hooks const planQuery = useFetchNutritionalPlanDateQuery(planId, params.date!); diff --git a/src/components/Nutrition/widgets/DiaryOverview.tsx b/src/components/Nutrition/widgets/DiaryOverview.tsx index eca65043..a1bacf26 100644 --- a/src/components/Nutrition/widgets/DiaryOverview.tsx +++ b/src/components/Nutrition/widgets/DiaryOverview.tsx @@ -4,7 +4,7 @@ import { GroupedDiaryEntries } from "@/components/Nutrition/models/nutritionalPl import React from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; -import { dateToLocale } from "@/core/lib/date"; +import { dateToLocale, yyyymmddToDate } from "@/core/lib/date"; import { numberLocale } from "@/core/lib/numbers"; import { makeLink, WgerLink } from "@/core/lib/url"; @@ -32,7 +32,7 @@ export const DiaryOverview = (props: { - {dateToLocale(new Date(key))} + {dateToLocale(yyyymmddToDate(key))} diff --git a/src/components/Nutrition/widgets/forms/PlanForm.tsx b/src/components/Nutrition/widgets/forms/PlanForm.tsx index 9a570ab0..f19ccd3d 100644 --- a/src/components/Nutrition/widgets/forms/PlanForm.tsx +++ b/src/components/Nutrition/widgets/forms/PlanForm.tsx @@ -20,7 +20,7 @@ import i18n from "@/i18n"; import { DateTime } from "luxon"; import React, { useState } from 'react'; import { useTranslation } from "react-i18next"; -import { dateToYYYYMMDD } from "@/core/lib/date"; +import { dateToYYYYMMDD, yyyymmddToDate } from "@/core/lib/date"; import * as yup from 'yup'; interface PlanFormProps { @@ -117,8 +117,11 @@ export const PlanForm = ({ plan, closeFn }: PlanFormProps) => { const newPlan = new NutritionalPlan({ - start: new Date(values.start), - end: values.end ? new Date(values.end) : null, + // the values are YYYY-MM-DD strings, parse them as local dates: + // new Date() would interpret them as UTC midnight and shift the + // day in timezones behind UTC + start: yyyymmddToDate(values.start), + end: values.end ? yyyymmddToDate(values.end) : null, description: values.description, onlyLogging: values.onlyLogging, @@ -172,7 +175,7 @@ export const PlanForm = ({ plan, closeFn }: PlanFormProps) => { }} onChange={(newValue) => { if (newValue) { - formik.setFieldValue('start', newValue.toJSDate()); + formik.setFieldValue('start', dateToYYYYMMDD(newValue.toJSDate())); } setStartDateValue(newValue); }} @@ -197,7 +200,7 @@ export const PlanForm = ({ plan, closeFn }: PlanFormProps) => { }} onChange={(newValue) => { if (newValue) { - formik.setFieldValue('end', newValue.toJSDate()); + formik.setFieldValue('end', dateToYYYYMMDD(newValue.toJSDate())); } setEndDateValue(newValue); }} diff --git a/src/components/Routines/api/routine.test.ts b/src/components/Routines/api/routine.test.ts index 521c1e0e..6ec751e7 100644 --- a/src/components/Routines/api/routine.test.ts +++ b/src/components/Routines/api/routine.test.ts @@ -44,6 +44,7 @@ import { import axios from "axios"; import type { Mock } from 'vitest'; +import { yyyymmddToDate } from "@/core/lib/date"; vi.mock("axios"); vi.mock("@/components/Routines/api/workoutUnits"); @@ -72,8 +73,8 @@ describe("workout routine service tests", () => { name: 'My first routine!', description: 'Well rounded full body routine', created: new Date("2022-01-01T12:34:30+01:00"), - start: new Date("2024-03-01T00:00:00.000Z"), - end: new Date("2024-04-30T00:00:00.000Z"), + start: yyyymmddToDate("2024-03-01"), + end: yyyymmddToDate("2024-04-30"), fitInWeek: false, }), new Routine({ @@ -81,8 +82,8 @@ describe("workout routine service tests", () => { name: 'Beach body', description: 'Train only arms and chest, no legs!!!', created: new Date("2023-01-01T17:22:22+02:00"), - start: new Date("2024-03-01T00:00:00.000Z"), - end: new Date("2024-04-30T00:00:00.000Z"), + start: yyyymmddToDate("2024-03-01"), + end: yyyymmddToDate("2024-04-30"), fitInWeek: false, }), ]); @@ -162,7 +163,7 @@ describe("workout routine service tests", () => { expect(axios.get).toHaveBeenCalledTimes(1); expect(result[0].iteration).toStrictEqual(42); - expect(result[0].date).toStrictEqual(new Date('2024-04-01')); + expect(result[0].date).toStrictEqual(yyyymmddToDate('2024-04-01')); expect(result[0].label).toStrictEqual('first label'); expect(result[0].day).toStrictEqual( new Day({ @@ -279,8 +280,8 @@ describe("workout routine service tests", () => { const routine = new Routine({ name: 'New plan', description: 'desc', - start: new Date('2024-08-01'), - end: new Date('2024-09-01'), + start: yyyymmddToDate('2024-08-01'), + end: yyyymmddToDate('2024-09-01'), fitInWeek: true, }); (axios.post as Mock).mockResolvedValue({ data: responseAddRoutine }); @@ -308,8 +309,8 @@ describe("workout routine service tests", () => { id: 42, name: 'Edited', description: 'updated description', - start: new Date('2024-08-01'), - end: new Date('2024-09-01'), + start: yyyymmddToDate('2024-08-01'), + end: yyyymmddToDate('2024-09-01'), }); (axios.patch as Mock).mockResolvedValue({ data: responseEditRoutine }); diff --git a/src/components/Routines/models/Routine.test.ts b/src/components/Routines/models/Routine.test.ts index b9f8769f..dc85b9a4 100644 --- a/src/components/Routines/models/Routine.test.ts +++ b/src/components/Routines/models/Routine.test.ts @@ -7,7 +7,7 @@ import { testMuscleRectusAbdominis } from "@/tests/exerciseTestdata"; import { testRoutine1, testRoutineDayData1 } from "@/tests/workoutRoutinesTestData"; -import { isSameDay } from "@/core/lib/date"; +import { isSameDay, yyyymmddToDate } from "@/core/lib/date"; describe('Routine model tests', () => { @@ -101,8 +101,8 @@ describe('Routine model tests', () => { test('correctly calculates the iteration for a date', () => { // Assert - expect(routine.getIteration(new Date('2024-01-01'))).toEqual(null); - expect(routine.getIteration(new Date('2024-05-05'))).toEqual(1); + expect(routine.getIteration(yyyymmddToDate('2024-01-01'))).toEqual(null); + expect(routine.getIteration(yyyymmddToDate('2024-05-05'))).toEqual(1); }); test('correctly returns the DayData for an iteration', () => { diff --git a/src/components/Routines/models/Routine.ts b/src/components/Routines/models/Routine.ts index 4726820f..ba774139 100644 --- a/src/components/Routines/models/Routine.ts +++ b/src/components/Routines/models/Routine.ts @@ -3,7 +3,7 @@ import { RoutineDayData } from "@/components/Routines/models/RoutineDayData"; import i18n from 'i18next'; import { DateTime } from "luxon"; import { Adapter } from "@/core/lib/Adapter"; -import { dateToYYYYMMDD, isSameDay } from "@/core/lib/date"; +import { dateToYYYYMMDD, isSameDay, yyyymmddToDate } from "@/core/lib/date"; export const NAME_MIN_LENGTH = 3; export const NAME_MAX_LENGTH = 25; @@ -217,8 +217,8 @@ class RoutineAdapter implements Adapter { name: item.name, description: item.description, created: new Date(item.created), - start: new Date(item.start), - end: new Date(item.end), + start: yyyymmddToDate(item.start), + end: yyyymmddToDate(item.end), fitInWeek: item.fit_in_week, isTemplate: item.is_template, isPublic: item.is_public, diff --git a/src/components/Routines/models/RoutineDayData.ts b/src/components/Routines/models/RoutineDayData.ts index c05caac8..8a6667c6 100644 --- a/src/components/Routines/models/RoutineDayData.ts +++ b/src/components/Routines/models/RoutineDayData.ts @@ -1,6 +1,7 @@ import { Day } from "@/components/Routines/models/Day"; import { SlotData, SlotDataAdapter } from "@/components/Routines/models/SlotData"; import { Adapter } from "@/core/lib/Adapter"; +import { yyyymmddToDate } from "@/core/lib/date"; export class RoutineDayData { @@ -27,7 +28,7 @@ class RoutineDayDataAdapter implements Adapter { // eslint-disable-next-line @typescript-eslint/no-explicit-any fromJson = (item: any) => new RoutineDayData( item.iteration, - new Date(item.date), + yyyymmddToDate(item.date), item.label, item.day != null ? Day.fromJson(item.day) : null, // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/components/Routines/models/WorkoutSession.ts b/src/components/Routines/models/WorkoutSession.ts index 1a6d3de7..2cb6fc57 100644 --- a/src/components/Routines/models/WorkoutSession.ts +++ b/src/components/Routines/models/WorkoutSession.ts @@ -2,7 +2,7 @@ import { Day } from "@/components/Routines/models/Day"; import { WorkoutLog } from "@/components/Routines/models/WorkoutLog"; import i18n from 'i18next'; import { Adapter } from "@/core/lib/Adapter"; -import { dateTimeToHHMM, dateToYYYYMMDD, HHMMToDateTime } from "@/core/lib/date"; +import { dateTimeToHHMM, dateToYYYYMMDD, HHMMToDateTime, yyyymmddToDate } from "@/core/lib/date"; export const NOTES_MAX_LENGTH = 1000 as const; @@ -99,7 +99,7 @@ export class WorkoutSessionAdapter implements Adapter { id: item.id, dayId: item.day!, routineId: item.routine!, - date: new Date(item.date!), + date: yyyymmddToDate(item.date!), notes: item.notes !== undefined ? item.notes : null, impression: item.impression!, timeStart: item.time_start !== undefined ? HHMMToDateTime(item.time_start) : null, diff --git a/src/components/Routines/widgets/RoutineStatistics.tsx b/src/components/Routines/widgets/RoutineStatistics.tsx index b3ffdc21..06285072 100644 --- a/src/components/Routines/widgets/RoutineStatistics.tsx +++ b/src/components/Routines/widgets/RoutineStatistics.tsx @@ -1,6 +1,6 @@ import { Exercise, Language, Muscle } from "@/components/Exercises"; import { GroupedLogData, LogData, RoutineStatsData } from "@/components/Routines/models/LogStats"; -import { dateToLocale } from "@/core/lib/date"; +import { dateToLocale, yyyymmddToDate } from "@/core/lib/date"; import { FormControl, MenuItem, Select } from "@mui/material"; import InputLabel from "@mui/material/InputLabel"; import { SelectChangeEvent } from "@mui/material/Select"; @@ -203,7 +203,7 @@ export const getFullStatsData = ( calculateLoopSum(data, logData); return { - key: dateToLocale(new Date(date)), + key: dateToLocale(yyyymmddToDate(date)), values: allHeaders.map(header => data[calculateStatsData(selectedValueGroupBy, logData).headers.indexOf(header)]) }; }); diff --git a/src/components/Routines/widgets/forms/SessionLogsForm.test.tsx b/src/components/Routines/widgets/forms/SessionLogsForm.test.tsx index 5ed19d92..9e31e317 100644 --- a/src/components/Routines/widgets/forms/SessionLogsForm.test.tsx +++ b/src/components/Routines/widgets/forms/SessionLogsForm.test.tsx @@ -69,7 +69,7 @@ describe('SessionLogsForm', () => { render(); const weightElements = screen.getAllByRole('textbox').filter(input => (input as HTMLInputElement).value === '20'); @@ -100,7 +100,7 @@ describe('SessionLogsForm', () => { render(); await user.click(screen.getByTestId('AddIcon')); await user.click(screen.getByRole('button', { name: /submit/i })); diff --git a/src/components/Weight/widgets/Table/index.tsx b/src/components/Weight/widgets/Table/index.tsx index a14db95e..53bce626 100644 --- a/src/components/Weight/widgets/Table/index.tsx +++ b/src/components/Weight/widgets/Table/index.tsx @@ -85,14 +85,14 @@ export const WeightTable = ({ weights }: WeightTableProps) => { { field: 'date', headerName: t('date'), - type: 'date', - width: 140, + type: 'dateTime', + width: 160, editable: true, valueFormatter: (value?: Date) => { if (value == null) { return ''; } - return luxonDateTimeToLocale(DateTime.fromJSDate(value)); + return luxonDateTimeToLocale(DateTime.fromJSDate(value), undefined, DateTime.DATETIME_SHORT); }, }, { diff --git a/src/config.ts b/src/config.ts index 9c000abe..428eb23e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,7 +5,6 @@ export const IS_PROD = import.meta.env.PROD; export const PUBLIC_URL = IS_PROD ? "/static/node/@wger-project/react-components/build" : import.meta.env.VITE_PUBLIC_URL; export const SERVER_URL = IS_PROD ? "" : import.meta.env.VITE_API_SERVER; -export const TIME_ZONE = import.meta.env.VITE_TIME_ZONE; export const MIN_ACCOUNT_AGE_TO_TRUST = import.meta.env.VITE_MIN_ACCOUNT_AGE_TO_TRUST; export const VITE_API_SERVER = import.meta.env.VITE_API_SERVER; diff --git a/src/core/lib/consts.ts b/src/core/lib/consts.ts index 18c236e2..f427982a 100644 --- a/src/core/lib/consts.ts +++ b/src/core/lib/consts.ts @@ -1,5 +1,5 @@ import { Language } from "@/components/Exercises/models/language"; -import { MIN_ACCOUNT_AGE_TO_TRUST, TIME_ZONE } from "@/config"; +import { MIN_ACCOUNT_AGE_TO_TRUST } from "@/config"; export const ENGLISH_LANGUAGE_ID = 2; export const ENGLISH_LANGUAGE_CODE = 'en'; @@ -157,9 +157,6 @@ export const PAGINATION_OPTIONS = { pageSize: 10, }; - -export const TIMEZONE = TIME_ZONE || 'Europe/Berlin'; - export const LANGUAGE_SHORT_ENGLISH = 'en'; export const SNACKBAR_AUTO_HIDE_DURATION = 3000; diff --git a/src/core/lib/date.test.ts b/src/core/lib/date.test.ts index 9524f130..5b84d7f7 100644 --- a/src/core/lib/date.test.ts +++ b/src/core/lib/date.test.ts @@ -1,56 +1,115 @@ -import { calculatePastDate, dateTimeToHHMM, dateToYYYYMMDD } from "@/core/lib/date"; +import { calculatePastDate, dateTimeToHHMM, dateToYYYYMMDD, yyyymmddToDate } from "@/core/lib/date"; -describe("test date utility", () => { +/* + * All date helpers must behave the same in every timezone, so the whole suite + * runs three times: behind UTC, at UTC and ahead of UTC. + * + * Assigning process.env.TZ at runtime resets Node's timezone cache (POSIX + * only, works on Linux and macOS). This trick is limited to tests that build + * all their dates inside the test body - dates created at module load time + * (e.g. the shared fixtures in src/tests/) keep the timezone that was active + * during import. For those, the full test suite is run with different TZ + * values in the CI instead. + */ +const originalTz = process.env.TZ; - test('convert date 1', () => { - const result = dateToYYYYMMDD(new Date(2022, 0, 1, 23)); - expect(result).toStrictEqual('2022-01-01'); +describe.each([ + ['America/Toronto', 300], + ['UTC', 0], + ['Asia/Kolkata', -330], +])('with TZ=%s', (tz, expectedOffset) => { + + beforeAll(() => { + process.env.TZ = tz; }); - test('convert date 2', () => { - const result = dateToYYYYMMDD(new Date(2022, 5, 2, 23, 10, 34)); - expect(result).toStrictEqual('2022-06-02'); + afterAll(() => { + process.env.TZ = originalTz; }); - test('convert date 3', () => { - const result = dateToYYYYMMDD(new Date('January 17, 2022 03:24:00')); - expect(result).toStrictEqual('2022-01-17'); + test('sanity check: the timezone switch actually works', () => { + // in winter, when no DST is in effect anywhere + expect(new Date(2022, 0, 15).getTimezoneOffset()).toBe(expectedOffset); }); -}); -describe("test time utility", () => { + describe("test date utility", () => { + + test('convert date 1', () => { + const result = dateToYYYYMMDD(new Date(2022, 0, 1, 23)); + expect(result).toStrictEqual('2022-01-01'); + }); + + test('convert date 2', () => { + const result = dateToYYYYMMDD(new Date(2022, 5, 2, 23, 10, 34)); + expect(result).toStrictEqual('2022-06-02'); + }); - test('convert time 1', () => { - const result = dateTimeToHHMM(new Date(2022, 0, 1, 23, 10, 22)); - expect(result).toStrictEqual('23:10'); + test('convert date 3', () => { + const result = dateToYYYYMMDD(new Date('January 17, 2022 03:24:00')); + expect(result).toStrictEqual('2022-01-17'); + }); + + test('convert date at local midnight', () => { + const result = dateToYYYYMMDD(new Date(2022, 0, 1)); + expect(result).toStrictEqual('2022-01-01'); + }); }); -}); + describe("test yyyymmddToDate", () => { + test('parses to local midnight', () => { + const result = yyyymmddToDate('2023-05-01'); + expect(result).toStrictEqual(new Date(2023, 4, 1)); + }); -describe('calculatePastDate', () => { + // These must hold in every timezone, otherwise dates drift by one day + // on each load / save cycle + test('roundtrip string -> date -> string', () => { + expect(dateToYYYYMMDD(yyyymmddToDate('2025-05-01'))).toStrictEqual('2025-05-01'); + expect(dateToYYYYMMDD(yyyymmddToDate('2024-12-31'))).toStrictEqual('2024-12-31'); + expect(dateToYYYYMMDD(yyyymmddToDate('2024-02-29'))).toStrictEqual('2024-02-29'); + }); - it('should return undefined for empty string filter', () => { - expect(calculatePastDate('', new Date('2023-08-14'))).toBeUndefined(); + test('roundtrip date -> string -> date', () => { + const date = new Date(2025, 6, 24); + expect(yyyymmddToDate(dateToYYYYMMDD(date))).toStrictEqual(date); + }); }); - it('should return the correct date for lastWeek filter', () => { - const result = calculatePastDate('lastWeek', new Date('2023-02-14')); - expect(result).toStrictEqual('2023-02-07'); - }); + describe("test time utility", () => { - it('should return the correct date for lastMonth filter', () => { - const result = calculatePastDate('lastMonth', new Date('2023-02-14')); - expect(result).toStrictEqual('2023-01-14'); - }); + test('convert time 1', () => { + const result = dateTimeToHHMM(new Date(2022, 0, 1, 23, 10, 22)); + expect(result).toStrictEqual('23:10'); + }); - it('should return the correct date for lastHalfYear filter', () => { - const result = calculatePastDate('lastHalfYear', new Date('2023-08-14')); - expect(result).toStrictEqual('2023-02-14'); }); - it('should return the correct date for lastYear filter', () => { - const result = calculatePastDate('lastYear', new Date('2023-02-14')); - expect(result).toStrictEqual('2022-02-14'); + + describe('calculatePastDate', () => { + + it('should return undefined for empty string filter', () => { + expect(calculatePastDate('', yyyymmddToDate('2023-08-14'))).toBeUndefined(); + }); + + it('should return the correct date for lastWeek filter', () => { + const result = calculatePastDate('lastWeek', yyyymmddToDate('2023-02-14')); + expect(result).toStrictEqual('2023-02-07'); + }); + + it('should return the correct date for lastMonth filter', () => { + const result = calculatePastDate('lastMonth', yyyymmddToDate('2023-02-14')); + expect(result).toStrictEqual('2023-01-14'); + }); + + it('should return the correct date for lastHalfYear filter', () => { + const result = calculatePastDate('lastHalfYear', yyyymmddToDate('2023-08-14')); + expect(result).toStrictEqual('2023-02-14'); + }); + + it('should return the correct date for lastYear filter', () => { + const result = calculatePastDate('lastYear', yyyymmddToDate('2023-02-14')); + expect(result).toStrictEqual('2022-02-14'); + }); }); -}); \ No newline at end of file +}); diff --git a/src/core/lib/date.ts b/src/core/lib/date.ts index 21f5611d..8cc41457 100644 --- a/src/core/lib/date.ts +++ b/src/core/lib/date.ts @@ -13,9 +13,31 @@ export function isSameDay(date1: Date, date2: Date): boolean { /* * Util function that converts a date to a YYYY-MM-DD string + * + * This is built from the local date components on purpose: the shorter + * date.toISOString().split('T')[0] first converts to UTC, so for dates like + * "local midnight" it returns the previous day for every timezone ahead of UTC + * (and the counterpart yyyymmddToDate would shift behind UTC). Since these + * strings represent calendar dates the user picked (Django DateFields), the + * local calendar day is the correct one. */ export function dateToYYYYMMDD(date: Date): string { - return date.toISOString().split('T')[0]; + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +/* + * Util function that parses a YYYY-MM-DD string (as returned by Django DateFields) + * to a Date at local midnight. + * + * Note that new Date("YYYY-MM-DD") must not be used for these strings: the spec + * parses them as UTC midnight, which is the previous day in timezones behind UTC. + */ +export function yyyymmddToDate(dateStr: string): Date { + const [year, month, day] = dateStr.split('-').map(Number); + return new Date(year, month - 1, day); } diff --git a/src/tests/nutritionDiaryTestdata.ts b/src/tests/nutritionDiaryTestdata.ts index 70674f0c..558a173d 100644 --- a/src/tests/nutritionDiaryTestdata.ts +++ b/src/tests/nutritionDiaryTestdata.ts @@ -1,4 +1,5 @@ import { DiaryEntry } from "@/components/Nutrition/models/diaryEntry"; +import { yyyymmddToDate } from "@/core/lib/date"; import { TEST_INGREDIENT_1, TEST_INGREDIENT_2, TEST_INGREDIENT_3, TEST_INGREDIENT_4 } from "@/tests/ingredientTestdata"; @@ -9,7 +10,7 @@ export const TEST_DIARY_ENTRY_1 = new DiaryEntry({ ingredientId: 101, weightUnitId: null, amount: 120, - datetime: new Date("2023-07-01"), + datetime: yyyymmddToDate("2023-07-01"), ingredient: TEST_INGREDIENT_1 }); @@ -20,7 +21,7 @@ export const TEST_DIARY_ENTRY_2 = new DiaryEntry({ ingredientId: 102, weightUnitId: null, amount: 50, - datetime: new Date("2023-07-01"), + datetime: yyyymmddToDate("2023-07-01"), ingredient: TEST_INGREDIENT_2 }); @@ -31,7 +32,7 @@ export const TEST_DIARY_ENTRY_3 = new DiaryEntry({ ingredientId: 103, weightUnitId: null, amount: 200, - datetime: new Date("2023-07-01"), + datetime: yyyymmddToDate("2023-07-01"), ingredient: TEST_INGREDIENT_3 }); @@ -42,7 +43,7 @@ export const TEST_DIARY_ENTRY_4 = new DiaryEntry({ ingredientId: 103, weightUnitId: null, amount: 20, - datetime: new Date("2023-07-02"), + datetime: yyyymmddToDate("2023-07-02"), ingredient: TEST_INGREDIENT_3 }); @@ -53,7 +54,7 @@ export const TEST_DIARY_ENTRY_5 = new DiaryEntry({ ingredientId: 103, weightUnitId: null, amount: 20, - datetime: new Date("2023-07-03"), + datetime: yyyymmddToDate("2023-07-03"), ingredient: TEST_INGREDIENT_3 }); @@ -64,7 +65,7 @@ export const TEST_DIARY_ENTRY_6 = new DiaryEntry({ ingredientId: 103, weightUnitId: null, amount: 50, - datetime: new Date("2023-07-04"), + datetime: yyyymmddToDate("2023-07-04"), ingredient: TEST_INGREDIENT_3 }); @@ -75,7 +76,7 @@ export const TEST_DIARY_ENTRY_7 = new DiaryEntry({ ingredientId: 103, weightUnitId: null, amount: 50, - datetime: new Date("2023-07-05"), + datetime: yyyymmddToDate("2023-07-05"), ingredient: TEST_INGREDIENT_3 }); @@ -86,7 +87,7 @@ export const TEST_DIARY_ENTRY_8 = new DiaryEntry({ ingredientId: 103, weightUnitId: null, amount: 100, - datetime: new Date("2023-07-06"), + datetime: yyyymmddToDate("2023-07-06"), ingredient: TEST_INGREDIENT_3 }); @@ -97,7 +98,7 @@ export const TEST_DIARY_ENTRY_9 = new DiaryEntry({ ingredientId: 103, weightUnitId: null, amount: 80, - datetime: new Date("2023-07-07"), + datetime: yyyymmddToDate("2023-07-07"), ingredient: TEST_INGREDIENT_3 }); @@ -108,7 +109,7 @@ export const TEST_DIARY_ENTRY_10 = new DiaryEntry({ ingredientId: 103, weightUnitId: null, amount: 80, - datetime: new Date("2023-07-08"), + datetime: yyyymmddToDate("2023-07-08"), ingredient: TEST_INGREDIENT_3 }); @@ -119,7 +120,7 @@ export const TEST_DIARY_ENTRY_11 = new DiaryEntry({ ingredientId: 103, weightUnitId: null, amount: 500, - datetime: new Date("2023-06-01"), + datetime: yyyymmddToDate("2023-06-01"), ingredient: TEST_INGREDIENT_3 }); @@ -130,7 +131,7 @@ export const TEST_DIARY_ENTRY_12 = new DiaryEntry({ ingredientId: 104, weightUnitId: null, amount: 500, - datetime: new Date("2023-06-15"), + datetime: yyyymmddToDate("2023-06-15"), ingredient: TEST_INGREDIENT_4 }); @@ -141,7 +142,7 @@ export const TEST_DIARY_ENTRY_13 = new DiaryEntry({ ingredientId: 104, weightUnitId: null, amount: 500, - datetime: new Date("2023-06-20"), + datetime: yyyymmddToDate("2023-06-20"), ingredient: TEST_INGREDIENT_4 }); @@ -152,7 +153,7 @@ export const TEST_DIARY_ENTRY_14 = new DiaryEntry({ ingredientId: 104, weightUnitId: null, amount: 20, - datetime: new Date("2023-08-20"), + datetime: yyyymmddToDate("2023-08-20"), ingredient: TEST_INGREDIENT_4 }); export const TEST_DIARY_ENTRY_15 = new DiaryEntry({ @@ -162,6 +163,6 @@ export const TEST_DIARY_ENTRY_15 = new DiaryEntry({ ingredientId: 103, weightUnitId: null, amount: 50, - datetime: new Date("2023-08-20"), + datetime: yyyymmddToDate("2023-08-20"), ingredient: TEST_INGREDIENT_4 }); diff --git a/src/tests/nutritionTestdata.ts b/src/tests/nutritionTestdata.ts index c155b7ae..f1e374e5 100644 --- a/src/tests/nutritionTestdata.ts +++ b/src/tests/nutritionTestdata.ts @@ -2,7 +2,7 @@ import { Meal } from "@/components/Nutrition/models/meal"; import { MealItem } from "@/components/Nutrition/models/mealItem"; import { NutritionalPlan } from "@/components/Nutrition/models/nutritionalPlan"; import { NutritionWeightUnit } from "@/components/Nutrition/models/weightUnit"; -import { HHMMToDateTime } from "@/core/lib/date"; +import { HHMMToDateTime, yyyymmddToDate } from "@/core/lib/date"; import { TEST_INGREDIENT_1, TEST_INGREDIENT_2, @@ -162,7 +162,7 @@ TEST_MEAL_5.items = [TEST_MEAL_ITEM_8]; export const TEST_NUTRITIONAL_PLAN_1 = new NutritionalPlan({ id: 'aaaaaaaa-0000-0000-0000-000000000101', - creationDate: new Date('2023-01-01'), + creationDate: yyyymmddToDate('2023-01-01'), description: 'Summer body!!!', }); TEST_NUTRITIONAL_PLAN_1.meals = [ @@ -192,7 +192,7 @@ TEST_NUTRITIONAL_PLAN_1.diaryEntries = [ export const TEST_NUTRITIONAL_PLAN_2 = new NutritionalPlan({ id: 'aaaaaaaa-0000-0000-0000-000000000222', - creationDate: new Date('2023-08-01'), + creationDate: yyyymmddToDate('2023-08-01'), description: 'Bulking till we puke', }); TEST_NUTRITIONAL_PLAN_2.meals = [TEST_MEAL_4, TEST_MEAL_5]; diff --git a/src/tests/setup.ts b/src/tests/setup.ts index 1055e940..424733c9 100644 --- a/src/tests/setup.ts +++ b/src/tests/setup.ts @@ -22,7 +22,6 @@ vi.mock('@/config', () => { IS_PROD: false, PUBLIC_URL: '', SERVER_URL: 'https://example.com', - TIME_ZONE: 'UTC', MIN_ACCOUNT_AGE_TO_TRUST: 21, VITE_API_SERVER: 'https://example.com', VITE_API_KEY: '122333444455555666666', diff --git a/src/tests/workoutRoutinesTestData.ts b/src/tests/workoutRoutinesTestData.ts index f27abb6d..7779e783 100644 --- a/src/tests/workoutRoutinesTestData.ts +++ b/src/tests/workoutRoutinesTestData.ts @@ -10,6 +10,7 @@ import { SlotData } from "@/components/Routines/models/SlotData"; import { SlotEntry } from "@/components/Routines/models/SlotEntry"; import { WeightUnit } from "@/components/Routines/models/WeightUnit"; import { WorkoutSession } from "@/components/Routines/models/WorkoutSession"; +import { yyyymmddToDate } from "@/core/lib/date"; import { testExerciseBenchPress, testExerciseSquats } from "@/tests/exerciseTestdata"; import { testWorkoutLogs } from "@/tests/workoutLogsRoutinesTestData"; @@ -162,7 +163,7 @@ const testRestDay = new Day({ export const testRoutineDayData1 = [ new RoutineDayData( 1, - new Date('2024-05-05'), + yyyymmddToDate('2024-05-05'), '', testDayLegs, [ @@ -208,7 +209,7 @@ export const testRoutineLogData = [ id: 'bbbbbbbb-bbbb-bbbb-bbbb-000000000111', dayId: 2, routineId: 1, - date: new Date('2024-07-01'), + date: yyyymmddToDate('2024-07-01'), notes: 'everything was great today!', impression: '1', timeStart: new Date('2024-12-01 12:30'), @@ -223,8 +224,8 @@ export const testRoutine1 = new Routine({ name: 'Test routine 1', description: 'Full body routine', created: new Date('2024-01-01'), - start: new Date('2024-05-01'), - end: new Date('2024-06-01'), + start: yyyymmddToDate('2024-05-01'), + end: yyyymmddToDate('2024-06-01'), fitInWeek: false, isTemplate: false, isPublic: false, @@ -237,8 +238,8 @@ export const testRoutine2 = new Routine({ name: '', description: 'The routine description', created: new Date('2024-02-01'), - start: new Date('2024-02-01'), - end: new Date('2024-03-01'), + start: yyyymmddToDate('2024-02-01'), + end: yyyymmddToDate('2024-03-01'), fitInWeek: false, isTemplate: false, isPublic: false, @@ -249,8 +250,8 @@ export const testPublicTemplate1 = new Routine({ name: 'public template 1', description: 'lorem ipsum', created: new Date('2025-01-01'), - start: new Date('2025-01-10'), - end: new Date('2025-02-01'), + start: yyyymmddToDate('2025-01-10'), + end: yyyymmddToDate('2025-02-01'), fitInWeek: false, isTemplate: true, isPublic: true, @@ -261,8 +262,8 @@ export const testPrivateTemplate1 = new Routine({ name: 'private template 1', description: 'lorem ipsum', created: new Date('2025-01-01'), - start: new Date('2025-01-10'), - end: new Date('2025-02-01'), + start: yyyymmddToDate('2025-01-10'), + end: yyyymmddToDate('2025-02-01'), fitInWeek: false, isTemplate: true, isPublic: false, diff --git a/vite.config.ts b/vite.config.ts index 19aee0f6..94b64cfd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -39,12 +39,36 @@ export default defineConfig(({ mode }) => { globals: true, setupFiles: './src/tests/setup.ts', testTimeout: 15000, - include: ['src/**/*.{test,spec}.{ts,tsx}'], css: false, pool: 'threads', maxWorkers: '50%', minWorkers: 1, + // Note: `include` lives in the projects (extends merges arrays, a + // root-level include would make both projects run everything) + projects: [ + { + extends: true, + test: { + name: 'default', + include: ['src/**/*.{test,spec}.{ts,tsx}'], + exclude: ['src/core/lib/date.test.ts'], + }, + }, + { + extends: true, + test: { + name: 'timezones', + // These tests re-run themselves under several TZ values. + // They need child processes: assigning process.env.TZ at + // runtime only resets the cached timezone on a process + // main thread, worker threads keep the old one. + include: ['src/core/lib/date.test.ts'], + pool: 'forks', + }, + }, + ], + server: { deps: { // @mui/material@9.1+ imports react-transition-group/TransitionGroupContext