Skip to content

Commit 270109d

Browse files
feat(web): add mode-aware weekly rate in Deltas; remove duplicate weekly rate from Stats (#357)
* feat(web): add mode-aware weekly rate in Deltas; remove duplicate weekly rate from Stats Co-Authored-By: Erv Walter <erv@ewal.net> * feat(web): move weekly rate above deltas in Deltas section Co-Authored-By: Erv Walter <erv@ewal.net> * test(web): add comprehensive Deltas weekly rate tests and fix matchers; ensure mt-2 mb-2 spacing and third-person wording Co-Authored-By: Erv Walter <erv@ewal.net> * fix(web): remove early return for empty deltas and improve spacing in Deltas component * fix(web): update Deltas test to match new behavior showing weekly rate without deltas The Deltas component now always displays the weekly rate even when there are no deltas, but the test was still expecting null. Updated the test to verify: - Weekly rate is displayed when no deltas exist - Heading and rate text are shown - No delta items are rendered - Fixed text matching from "/week" to "per week" in other tests --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Erv Walter <erv@ewal.net>
1 parent d7be013 commit 270109d

4 files changed

Lines changed: 131 additions & 51 deletions

File tree

apps/web/src/components/dashboard/deltas.test.tsx

Lines changed: 101 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { describe, it, expect, vi, beforeEach } from "vitest";
2-
import { render, screen } from "@testing-library/react";
1+
import type { DashboardData } from "@/lib/dashboard/dashboard-context";
32
import { LocalDate } from "@js-joda/core";
3+
import { render, screen } from "@testing-library/react";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
45
import Deltas from "./deltas";
5-
import type { DashboardData } from "@/lib/dashboard/dashboard-context";
66

7-
// Mock the dashboard hooks
87
vi.mock("@/lib/dashboard/hooks", () => ({
98
useDashboardData: vi.fn(),
109
}));
@@ -33,15 +32,32 @@ describe("Deltas", () => {
3332
} as any,
3433
};
3534

35+
const withDeltas = (overrides: Partial<DashboardData> = {}) =>
36+
({
37+
...defaultMockData,
38+
deltas: [
39+
{ period: "week", description: "1 week ago", delta: -2 },
40+
{ period: "month", description: "1 month ago", delta: -5 },
41+
],
42+
activeSlope: -0.1,
43+
...overrides,
44+
}) as any;
45+
3646
beforeEach(() => {
3747
vi.clearAllMocks();
3848
});
3949

40-
it("returns null when no deltas", () => {
41-
mockUseDashboardData.mockReturnValue(defaultMockData as any);
42-
43-
const { container } = render(<Deltas />);
44-
expect(container.firstChild).toBeNull();
50+
it("shows weekly rate but no deltas when no deltas", () => {
51+
mockUseDashboardData.mockReturnValue({
52+
...defaultMockData,
53+
activeSlope: -0.1,
54+
isMe: true,
55+
} as any);
56+
render(<Deltas />);
57+
expect(screen.getByText("Weight Changes Over Time")).toBeInTheDocument();
58+
expect(screen.getByText(/You are losing/)).toBeInTheDocument();
59+
expect(screen.getByText(/per week/)).toBeInTheDocument();
60+
expect(screen.queryByText(/Since .* ago:/)).not.toBeInTheDocument();
4561
});
4662

4763
it("renders weight deltas with correct intended direction", () => {
@@ -52,9 +68,7 @@ describe("Deltas", () => {
5268
{ period: "month", description: "1 month ago", delta: -5 },
5369
],
5470
} as any);
55-
5671
render(<Deltas />);
57-
5872
expect(screen.getByText("Weight Changes Over Time")).toBeInTheDocument();
5973
expect(screen.getByText(/Since 1 week ago:/)).toBeInTheDocument();
6074
expect(screen.getByText(/Since 1 month ago:/)).toBeInTheDocument();
@@ -63,39 +77,33 @@ describe("Deltas", () => {
6377
it("renders fat percent deltas with negative intended direction", () => {
6478
mockUseDashboardData.mockReturnValue({
6579
...defaultMockData,
66-
mode: ["fatpercent"],
80+
mode: ["fatpercent", () => {}],
6781
deltas: [
6882
{ period: "week", description: "1 week ago", delta: -0.5 },
6983
{ period: "month", description: "1 month ago", delta: -1.2 },
7084
],
7185
} as any);
72-
7386
render(<Deltas />);
74-
7587
expect(screen.getByText("Fat % Changes Over Time")).toBeInTheDocument();
7688
});
7789

7890
it("renders fat mass deltas with negative intended direction", () => {
7991
mockUseDashboardData.mockReturnValue({
8092
...defaultMockData,
81-
mode: ["fatmass"],
93+
mode: ["fatmass", () => {}],
8294
deltas: [{ period: "week", description: "1 week ago", delta: -2.5 }],
8395
} as any);
84-
8596
render(<Deltas />);
86-
8797
expect(screen.getByText("Fat Mass Changes Over Time")).toBeInTheDocument();
8898
});
8999

90100
it("renders lean mass deltas with positive intended direction", () => {
91101
mockUseDashboardData.mockReturnValue({
92102
...defaultMockData,
93-
mode: ["leanmass"],
103+
mode: ["leanmass", () => {}],
94104
deltas: [{ period: "week", description: "1 week ago", delta: 1.5 }],
95105
} as any);
96-
97106
render(<Deltas />);
98-
99107
expect(screen.getByText("Lean Mass Changes Over Time")).toBeInTheDocument();
100108
});
101109

@@ -118,10 +126,7 @@ describe("Deltas", () => {
118126
],
119127
deltas: [{ period: "week", description: "1 week ago", delta: -2 }],
120128
} as any);
121-
122129
render(<Deltas />);
123-
124-
// Should calculate intended direction as negative (160 - 180 = -20)
125130
expect(screen.getByText(/Since 1 week ago:/)).toBeInTheDocument();
126131
});
127132

@@ -135,9 +140,7 @@ describe("Deltas", () => {
135140
} as any,
136141
deltas: [{ period: "week", description: "1 week ago", delta: -2 }],
137142
} as any);
138-
139143
render(<Deltas />);
140-
141144
expect(screen.getByText(/Since 1 week ago:/)).toBeInTheDocument();
142145
});
143146

@@ -151,10 +154,7 @@ describe("Deltas", () => {
151154
} as any,
152155
deltas: [{ period: "week", description: "1 week ago", delta: -0.9 }],
153156
} as any);
154-
155157
render(<Deltas />);
156-
157-
// formatMeasurement should be called with metric: true
158158
expect(screen.getByText(/Since 1 week ago:/)).toBeInTheDocument();
159159
});
160160

@@ -168,14 +168,85 @@ describe("Deltas", () => {
168168
{ period: "year", description: "1 year ago", delta: -30 },
169169
],
170170
} as any);
171-
172171
render(<Deltas />);
173-
174172
const deltas = screen.getAllByText(/Since .* ago:/);
175173
expect(deltas).toHaveLength(4);
176174
expect(deltas[0]).toHaveTextContent("Since 1 week ago:");
177175
expect(deltas[1]).toHaveTextContent("Since 1 month ago:");
178176
expect(deltas[2]).toHaveTextContent("Since 3 months ago:");
179177
expect(deltas[3]).toHaveTextContent("Since 1 year ago:");
180178
});
179+
180+
it("shows weekly rate sentence for weight", () => {
181+
mockUseDashboardData.mockReturnValue(
182+
withDeltas({
183+
mode: ["weight", () => {}],
184+
isMe: true,
185+
activeSlope: -0.1,
186+
profile: { ...(defaultMockData.profile as any), useMetric: false } as any,
187+
}) as any,
188+
);
189+
render(<Deltas />);
190+
expect(screen.getByText(/You are losing/)).toBeInTheDocument();
191+
expect(screen.getByText("0.7 lb")).toBeInTheDocument();
192+
expect(screen.getAllByText((content) => content.includes("per week")).length).toBeGreaterThan(0);
193+
});
194+
195+
it("shows weekly rate sentence for fat percent", () => {
196+
mockUseDashboardData.mockReturnValue(
197+
withDeltas({
198+
mode: ["fatpercent", () => {}],
199+
isMe: true,
200+
activeSlope: 0.02,
201+
profile: { ...(defaultMockData.profile as any), useMetric: false } as any,
202+
}) as any,
203+
);
204+
render(<Deltas />);
205+
expect(screen.getByText(/You are gaining/)).toBeInTheDocument();
206+
expect(screen.getByText((_, node) => !!node && node.tagName === "STRONG" && /%$/.test(node.textContent || ""))).toBeInTheDocument();
207+
expect(screen.getAllByText((content) => content.includes("per week")).length).toBeGreaterThan(0);
208+
expect(screen.getByText(/of body fat/)).toBeInTheDocument();
209+
});
210+
211+
it("shows weekly rate sentence for fat mass (metric)", () => {
212+
mockUseDashboardData.mockReturnValue(
213+
withDeltas({
214+
mode: ["fatmass", () => {}],
215+
isMe: true,
216+
activeSlope: -0.05,
217+
profile: { ...(defaultMockData.profile as any), useMetric: true } as any,
218+
}) as any,
219+
);
220+
render(<Deltas />);
221+
expect(screen.getByText(/You are losing/)).toBeInTheDocument();
222+
expect(screen.getByText((_, node) => !!node && node.tagName === "STRONG" && /kg$/.test(node.textContent || ""))).toBeInTheDocument();
223+
expect(screen.getAllByText((content) => content.includes("per week")).length).toBeGreaterThan(0);
224+
expect(screen.getByText(/of fat mass/)).toBeInTheDocument();
225+
});
226+
227+
it("shows weekly rate sentence for lean mass (metric)", () => {
228+
mockUseDashboardData.mockReturnValue(
229+
withDeltas({
230+
mode: ["leanmass", () => {}],
231+
isMe: true,
232+
activeSlope: 0.03,
233+
profile: { ...(defaultMockData.profile as any), useMetric: true } as any,
234+
}) as any,
235+
);
236+
render(<Deltas />);
237+
expect(screen.getByText(/You are gaining/)).toBeInTheDocument();
238+
expect(screen.getByText(/of lean mass/)).toBeInTheDocument();
239+
});
240+
241+
it("uses third-person wording when not viewing own profile", () => {
242+
mockUseDashboardData.mockReturnValue(
243+
withDeltas({
244+
isMe: false,
245+
activeSlope: -0.1,
246+
profile: { ...(defaultMockData.profile as any), firstName: "Test", useMetric: false } as any,
247+
}) as any,
248+
);
249+
render(<Deltas />);
250+
expect(screen.getByText(/Test is losing/)).toBeInTheDocument();
251+
});
181252
});

apps/web/src/components/dashboard/deltas.tsx

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,20 @@
1+
import { Heading } from "@/components/common/heading";
12
import { Modes } from "@/lib/core/interfaces";
23
import { formatMeasurement } from "@/lib/core/numbers";
34
import { useDashboardData } from "@/lib/dashboard/hooks";
4-
import { Heading } from "@/components/common/heading";
5+
import { cn } from "@/lib/utils";
56
import ChangeArrow from "./change-arrow";
67

78
const Deltas = () => {
89
const {
910
deltas,
1011
mode: [mode],
1112
dataPoints,
12-
profile: { useMetric, plannedPoundsPerWeek, goalWeight },
13+
activeSlope,
14+
profile: { useMetric, plannedPoundsPerWeek, goalWeight, firstName },
15+
isMe,
1316
} = useDashboardData();
1417

15-
if (deltas.length === 0) {
16-
return null;
17-
}
18-
1918
const last = dataPoints[dataPoints.length - 1];
2019
let intendedDirection: number;
2120
if (mode === "weight") {
@@ -26,11 +25,27 @@ const Deltas = () => {
2625
intendedDirection = 1;
2726
}
2827

28+
const weeklyRate = activeSlope * 7;
29+
const isGaining = weeklyRate > 0;
30+
const absFormatted = formatMeasurement(Math.abs(weeklyRate), { type: mode, metric: useMetric, units: true, sign: false });
31+
let nounPhrase = "";
32+
if (mode === "fatpercent") {
33+
nounPhrase = " of body fat";
34+
} else if (mode === "fatmass") {
35+
nounPhrase = " of fat mass";
36+
} else if (mode === "leanmass") {
37+
nounPhrase = " of lean mass";
38+
}
39+
const verb = isGaining ? "gaining" : "losing";
40+
2941
return (
3042
<div>
3143
<Heading level={3} className="mb-3">
3244
{Modes[mode]} Changes Over Time
3345
</Heading>
46+
<div className={cn("mt-2", deltas.length > 0 ? "mb-4" : "mb-0")}>
47+
{isMe ? "You are" : `${firstName} is`} {verb} <strong>{absFormatted}</strong> {nounPhrase} per week
48+
</div>
3449
<div className="space-y-1">
3550
{deltas.map((d) => (
3651
<div key={d.period}>

apps/web/src/components/dashboard/stats.test.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,8 @@ describe("Stats", () => {
5353

5454
render(<Stats />);
5555

56-
expect(screen.getByText(/You are losing/)).toBeInTheDocument();
57-
expect(screen.getByText("1.4 lb/week")).toBeInTheDocument(); // 0.2 * 7
58-
expect(screen.getByText(/of total weight/)).toBeInTheDocument();
56+
// Weekly rate sentence removed from Stats; ensure component renders basic stats
57+
expect(screen.getByText(/Overall Weight Statistics/)).toBeInTheDocument();
5958
});
6059

6160
it("displays gaining weight stats", () => {
@@ -66,8 +65,8 @@ describe("Stats", () => {
6665

6766
render(<Stats />);
6867

69-
expect(screen.getByText(/You are gaining/)).toBeInTheDocument();
70-
expect(screen.getByText("1.1 lb/week")).toBeInTheDocument(); // 0.15 * 7, rounded
68+
// Weekly rate sentence removed from Stats; ensure component renders basic stats
69+
expect(screen.getByText(/Overall Weight Statistics/)).toBeInTheDocument();
7170
});
7271

7372
it("displays metric units when enabled", () => {
@@ -82,7 +81,8 @@ describe("Stats", () => {
8281

8382
render(<Stats />);
8483

85-
expect(screen.getByText("0.6 kg/week")).toBeInTheDocument(); // 0.09 * 7
84+
// Weekly rate sentence removed from Stats; ensure component renders basic stats
85+
expect(screen.getByText(/Overall Weight Statistics/)).toBeInTheDocument();
8686
});
8787

8888
it("uses third person when not viewing own profile", () => {
@@ -93,7 +93,6 @@ describe("Stats", () => {
9393

9494
render(<Stats />);
9595

96-
expect(screen.getByText(/Test is losing/)).toBeInTheDocument();
9796
expect(screen.getByText(/They have been tracking their weight/)).toBeInTheDocument();
9897
});
9998
});
@@ -363,8 +362,8 @@ describe("Stats", () => {
363362

364363
render(<Stats />);
365364

366-
expect(screen.getByText(/You are losing/)).toBeInTheDocument();
367-
expect(screen.getByText("0.0 lb/week")).toBeInTheDocument();
365+
// Weekly rate sentence removed from Stats; ensure component renders basic stats
366+
expect(screen.getByText(/Overall Weight Statistics/)).toBeInTheDocument();
368367
});
369368

370369
it("handles single measurement", () => {
@@ -398,7 +397,6 @@ describe("Stats", () => {
398397
expect(screen.queryByText(/will reach your goal (on|in)\/around/)).not.toBeInTheDocument();
399398

400399
// Should still show basic stats
401-
expect(screen.getByText(/You are losing/)).toBeInTheDocument();
402400
expect(screen.getByText(/to lose to reach your goal/)).toBeInTheDocument();
403401
});
404402
});

apps/web/src/components/dashboard/stats.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const Stats = () => {
99
const {
1010
weightSlope,
1111
measurements,
12-
profile: { useMetric, goalWeight, showCalories, plannedPoundsPerWeek, firstName },
12+
profile: { useMetric, goalWeight, showCalories, plannedPoundsPerWeek },
1313
isMe,
1414
} = useDashboardData();
1515

@@ -34,10 +34,6 @@ const Stats = () => {
3434
Overall Weight Statistics
3535
</Heading>
3636
<ul className="space-y-1">
37-
<li>
38-
{isMe ? "You are" : `${firstName} is`} {weightSlope > 0 ? "gaining" : "losing"} <strong>{formatWeight(Math.abs(gainPerWeek), useMetric)}/week</strong>{" "}
39-
of total weight.{" "}
40-
</li>
4137
<li className="mt-4">
4238
{isMe ? "You have" : "They have"} been tracking {isMe ? "your" : "their"} weight for <strong>{duration}</strong>.
4339
</li>

0 commit comments

Comments
 (0)