Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 }}
28 changes: 17 additions & 11 deletions src/components/Measurements/api/measurements.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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");
Expand All @@ -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": ""
}
]
Expand Down Expand Up @@ -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, "")
])
]);
});
Expand All @@ -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, "")
])
);
});
Expand Down Expand Up @@ -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({
Expand All @@ -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/<id>/ 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({
Expand All @@ -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);
});

Expand Down
7 changes: 4 additions & 3 deletions src/components/Measurements/api/measurements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
},
Expand All @@ -168,7 +168,8 @@ export const addMeasurementEntry = async (data: AddMeasurementParams): Promise<M
makeUrl(API_MEASUREMENTS_ENTRY_PATH),
{
category: data.categoryId,
date: dateToYYYYMMDD(data.date),
// the server field is a datetime, send the full timestamp
date: data.date.toISOString(),
value: data.value,
notes: data.notes
},
Expand Down
1 change: 1 addition & 0 deletions src/components/Measurements/models/Entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class MeasurementEntryAdapter implements Adapter<MeasurementEntry> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
},
{
Expand Down
5 changes: 1 addition & 4 deletions src/components/Measurements/widgets/EntryForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 12 additions & 11 deletions src/components/Nutrition/api/nutritionalPlan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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
}),
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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" },
Expand Down
6 changes: 4 additions & 2 deletions src/components/Nutrition/models/nutritionalPlan.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
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();

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(() => {
Expand All @@ -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);
Expand Down
12 changes: 7 additions & 5 deletions src/components/Nutrition/models/nutritionalPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down Expand Up @@ -142,7 +142,9 @@ export class NutritionalPlan {
get groupDiaryEntries(): Map<string, GroupedDiaryEntries> {

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);
Expand Down Expand Up @@ -220,9 +222,9 @@ export class NutritionalPlanAdapter implements Adapter<NutritionalPlan> {
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,
Expand Down
6 changes: 4 additions & 2 deletions src/components/Nutrition/queries/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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,
});
}
Expand Down
4 changes: 2 additions & 2 deletions src/components/Nutrition/screens/NutritionDiaryOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -21,7 +21,7 @@ export const NutritionDiaryOverview = () => {
return <p>Please pass a UUID as the nutritional plan id.</p>;
}

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!);

Expand Down
4 changes: 2 additions & 2 deletions src/components/Nutrition/widgets/DiaryOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -32,7 +32,7 @@ export const DiaryOverview = (props: {
<TableCell>
<Link
to={makeLink(WgerLink.NUTRITION_DIARY, i18n.language, { id: props.planId, date: key })}>
{dateToLocale(new Date(key))}
{dateToLocale(yyyymmddToDate(key))}
</Link>
</TableCell>
<TableCell align="right">
Expand Down
Loading
Loading