frontend: test the history mappers and feed helpers
Add device-free tests for the four history card mappers and the feed's merge, sort, group, and filter helpers. This pins the mood, sleep, trigger, and care-action summaries, badges, and label fallbacks — the exact 7 and 5 hour sleep thresholds, the link pluralisation, and the mood-summary shapes — so the chronological feed stops depending on a device to check. Also drop a stray debug log from the sleep mapper
Pedro Lucas Porcellis porcellis@eletrotupi.com · 9 days ago
dcee93d0c11ceff985805b7785c7db54c2acc4dd Browse filesParents
b0167efChanged files
3
Additions
+525
Deletions
-1
frontend/lib/history/mappers.ts
1 1
import type { HistoryCard } from "@/lib/history/types";
2 2
import type { MoodEntry, SleepRecord, Trigger, CareAction } from "@/lib/api";
3 3
import { getMood } from "@/constants/moods";
4 4
5 5
35 35
variant: "red",
36 36
};
37 37
}
38 38
39 39
export function mapSleepToHistoryCard(record: SleepRecord): HistoryCard {
40 -
console.log("mapping...", record)
41 40
const hours = Math.floor(record.average);
42 41
const minutes = Math.round((record.average - hours) * 60);
43 42
const duration = `${hours}h ${minutes.toString().padStart(2, "0")}min`;
44 43
const timestamp = typeof record.createdAt === 'string'
45 44
? record.createdAt
160 159
summary: "",
161 160
raw: record,
162 161
};
163 162
}
164 163
}
/dev/null → frontend/tests/history/mappers.unit.test.ts
1 +
import {
2 +
mapCareActionToHistoryCard,
3 +
mapMoodToHistoryCard,
4 +
mapSleepToHistoryCard,
5 +
mapTriggerToHistoryCard,
6 +
} from "@/lib/history/mappers";
7 +
import type {
8 +
ActivityType,
9 +
AppointmentType,
10 +
CareAction,
11 +
MoodEntry,
12 +
SleepRecord,
13 +
Trigger,
14 +
} from "@/lib/api";
15 +
import type { MoodComponent } from "@/lib/api/types";
16 +
17 +
const FIXED = new Date(2025, 0, 15, 10, 0, 0);
18 +
19 +
function mood(overrides: Partial<MoodEntry> = {}): MoodEntry {
20 +
return {
21 +
id: 1,
22 +
annotation: "",
23 +
moment: FIXED,
24 +
selectedMood: "good",
25 +
anxietyLevel: 2,
26 +
energyLevel: 4,
27 +
stressLevel: 1,
28 +
moodComponents: [],
29 +
createdAt: FIXED,
30 +
updatedAt: FIXED,
31 +
...overrides,
32 +
};
33 +
}
34 +
35 +
function moodComponent(component: string): MoodComponent {
36 +
return {
37 +
id: 1,
38 +
component,
39 +
intensity: "MILD",
40 +
createdAt: FIXED,
41 +
updatedAt: FIXED,
42 +
};
43 +
}
44 +
45 +
function sleep(overrides: Partial<SleepRecord> = {}): SleepRecord {
46 +
return {
47 +
id: 1,
48 +
annotations: "",
49 +
date: FIXED,
50 +
average: 8,
51 +
createdAt: FIXED,
52 +
updatedAt: FIXED,
53 +
...overrides,
54 +
};
55 +
}
56 +
57 +
function trigger(overrides: Partial<Trigger> = {}): Trigger {
58 +
return {
59 +
id: 1,
60 +
comment: "",
61 +
category: "Trabalho",
62 +
moment: FIXED,
63 +
createdAt: FIXED,
64 +
updatedAt: FIXED,
65 +
...overrides,
66 +
};
67 +
}
68 +
69 +
function careAction(overrides: Partial<CareAction> = {}): CareAction {
70 +
return {
71 +
id: 1,
72 +
type: "MEDICINE",
73 +
moment: FIXED,
74 +
userId: 1,
75 +
createdAt: FIXED,
76 +
updatedAt: FIXED,
77 +
...overrides,
78 +
};
79 +
}
80 +
81 +
describe("mapMoodToHistoryCard", () => {
82 +
it("maps the record into a mood card", () => {
83 +
const record = mood({
84 +
id: 7,
85 +
annotation: "Me senti bem hoje",
86 +
moment: new Date(2025, 5, 10, 14, 30),
87 +
selectedMood: "great",
88 +
moodComponents: [moodComponent("ANXIOUS"), moodComponent("CALM")],
89 +
});
90 +
91 +
expect(mapMoodToHistoryCard(record)).toStrictEqual({
92 +
id: "mood-7",
93 +
category: "mood",
94 +
timestamp: record.moment.toString(),
95 +
title: "Ótimo",
96 +
summary: "Anxious, Calm · Me senti bem hoje",
97 +
badge: undefined,
98 +
raw: record,
99 +
});
100 +
});
101 +
102 +
it("falls back to a generic title for an unknown mood", () => {
103 +
expect(mapMoodToHistoryCard(mood({ selectedMood: "unmapped" })).title).toBe(
104 +
"Humor",
105 +
);
106 +
});
107 +
108 +
describe("summary", () => {
109 +
it("joins only the components when there is no annotation", () => {
110 +
const card = mapMoodToHistoryCard(
111 +
mood({
112 +
annotation: "",
113 +
moodComponents: [moodComponent("ANXIOUS"), moodComponent("CALM")],
114 +
}),
115 +
);
116 +
117 +
expect(card.summary).toBe("Anxious, Calm");
118 +
});
119 +
120 +
it("uses only the annotation when there are no components", () => {
121 +
const card = mapMoodToHistoryCard(
122 +
mood({ annotation: "Tarde tranquila", moodComponents: [] }),
123 +
);
124 +
125 +
expect(card.summary).toBe("Tarde tranquila");
126 +
});
127 +
128 +
it("joins components and annotation when both are present", () => {
129 +
const card = mapMoodToHistoryCard(
130 +
mood({
131 +
annotation: "Tarde tranquila",
132 +
moodComponents: [moodComponent("CALM")],
133 +
}),
134 +
);
135 +
136 +
expect(card.summary).toBe("Calm · Tarde tranquila");
137 +
});
138 +
139 +
it("falls back when neither is present", () => {
140 +
const card = mapMoodToHistoryCard(
141 +
mood({ annotation: "", moodComponents: [] }),
142 +
);
143 +
144 +
expect(card.summary).toBe("Sem detalhes");
145 +
});
146 +
});
147 +
});
148 +
149 +
describe("mapSleepToHistoryCard", () => {
150 +
it("maps the record into a sleep card", () => {
151 +
const record = sleep({
152 +
id: 4,
153 +
average: 7.5,
154 +
annotations: "Dormi bem",
155 +
createdAt: new Date(2025, 0, 15, 8, 0, 0),
156 +
});
157 +
158 +
expect(mapSleepToHistoryCard(record)).toStrictEqual({
159 +
id: "sleep-4",
160 +
category: "sleep",
161 +
timestamp: record.createdAt.toISOString(),
162 +
title: "Sono",
163 +
summary: "7h 30min · Dormi bem",
164 +
badge: { label: "Excelente", variant: "green" },
165 +
raw: record,
166 +
});
167 +
});
168 +
169 +
it("keeps a string timestamp as-is", () => {
170 +
const timestamp = "2025-01-15T13:00:00.000Z";
171 +
const card = mapSleepToHistoryCard(
172 +
sleep({ createdAt: timestamp as unknown as Date }),
173 +
);
174 +
175 +
expect(card.timestamp).toBe(timestamp);
176 +
});
177 +
178 +
it("omits the separator when there is no annotation", () => {
179 +
expect(mapSleepToHistoryCard(sleep({ average: 8 })).summary).toBe(
180 +
"8h 00min",
181 +
);
182 +
});
183 +
184 +
describe("quality badge thresholds", () => {
185 +
it.each([
186 +
[9, "Excelente", "green"],
187 +
[7, "Excelente", "green"],
188 +
[6.99, "Regular", "yellow"],
189 +
[5, "Regular", "yellow"],
190 +
[4.99, "Ruim", "red"],
191 +
[0, "Ruim", "red"],
192 +
])("labels %p hours as %p", (average, label, variant) => {
193 +
expect(mapSleepToHistoryCard(sleep({ average })).badge).toEqual({
194 +
label,
195 +
variant,
196 +
});
197 +
});
198 +
});
199 +
200 +
it("pads the minutes to two digits", () => {
201 +
expect(mapSleepToHistoryCard(sleep({ average: 6.5 })).summary).toBe(
202 +
"6h 30min",
203 +
);
204 +
expect(mapSleepToHistoryCard(sleep({ average: 6.0167 })).summary).toBe(
205 +
"6h 01min",
206 +
);
207 +
});
208 +
});
209 +
210 +
describe("mapTriggerToHistoryCard", () => {
211 +
it("maps the record into a trigger card", () => {
212 +
const record = trigger({
213 +
id: 3,
214 +
category: "Trabalho",
215 +
comment: "Muito estresse",
216 +
moment: new Date(2025, 2, 1, 9, 0, 0),
217 +
});
218 +
219 +
expect(mapTriggerToHistoryCard(record)).toStrictEqual({
220 +
id: "trigger-3",
221 +
category: "trigger",
222 +
timestamp: record.moment.toString(),
223 +
title: "Gatilho",
224 +
summary: "Trabalho · Muito estresse",
225 +
badge: undefined,
226 +
raw: record,
227 +
});
228 +
});
229 +
230 +
it("drops the separator when there is no comment", () => {
231 +
const card = mapTriggerToHistoryCard(
232 +
trigger({ category: "Sono", comment: "" }),
233 +
);
234 +
235 +
expect(card.summary).toBe("Sono");
236 +
});
237 +
});
238 +
239 +
describe("mapCareActionToHistoryCard", () => {
240 +
describe("medicine", () => {
241 +
it("uses the regimen name and dosage", () => {
242 +
const record = careAction({
243 +
id: 10,
244 +
type: "MEDICINE",
245 +
medicineLog: {
246 +
id: 1,
247 +
careActionId: 10,
248 +
takenAt: FIXED,
249 +
regimen: {
250 +
id: 1,
251 +
name: "Ritalina",
252 +
dosage: "10mg",
253 +
periodicity: "DAILY",
254 +
scheduledAt: [],
255 +
active: true,
256 +
userId: 1,
257 +
createdAt: FIXED,
258 +
updatedAt: FIXED,
259 +
},
260 +
},
261 +
});
262 +
263 +
expect(mapCareActionToHistoryCard(record)).toStrictEqual({
264 +
id: "care_action-10",
265 +
category: "care_action",
266 +
subtype: "medicine",
267 +
timestamp: record.moment.toString(),
268 +
title: "Ritalina",
269 +
summary: "10mg",
270 +
raw: record,
271 +
});
272 +
});
273 +
274 +
it("falls back when there is no regimen", () => {
275 +
const card = mapCareActionToHistoryCard(careAction({ type: "MEDICINE" }));
276 +
277 +
expect(card.title).toBe("Medicamento");
278 +
expect(card.summary).toBe("Sem dosagem registrada");
279 +
});
280 +
});
281 +
282 +
describe("appointment", () => {
283 +
it("joins the duration and the note", () => {
284 +
const record = careAction({
285 +
id: 20,
286 +
type: "APPOINTMENT",
287 +
appointment: {
288 +
id: 1,
289 +
careActionId: 20,
290 +
type: "ANALYST",
291 +
duration: 50,
292 +
note: "Boa sessão",
293 +
},
294 +
});
295 +
296 +
expect(mapCareActionToHistoryCard(record)).toStrictEqual({
297 +
id: "care_action-20",
298 +
category: "care_action",
299 +
subtype: "appointment",
300 +
timestamp: record.moment.toString(),
301 +
title: "Consulta · Analista",
302 +
summary: "50 min · Boa sessão",
303 +
badge: undefined,
304 +
raw: record,
305 +
});
306 +
});
307 +
308 +
it("falls back when there are no details", () => {
309 +
const card = mapCareActionToHistoryCard(
310 +
careAction({ type: "APPOINTMENT", appointment: undefined }),
311 +
);
312 +
313 +
expect(card.summary).toBe("Sem anotações");
314 +
expect(card.title).toBe("Consulta · Consulta");
315 +
});
316 +
317 +
it.each([
318 +
["ANALYST", "Analista"],
319 +
["PSYCHIATRIST", "Psiquiatra"],
320 +
["GP", "Clínico geral"],
321 +
["NUTRITIONIST", "Nutricionista"],
322 +
["PHYSIOTHERAPIST", "Fisioterapeuta"],
323 +
["OTHER", "Consulta"],
324 +
["unmapped", "Consulta"],
325 +
] as const)("labels the %p appointment as %p", (type, label) => {
326 +
const card = mapCareActionToHistoryCard(
327 +
careAction({
328 +
type: "APPOINTMENT",
329 +
appointment: {
330 +
id: 1,
331 +
careActionId: 1,
332 +
type: type as AppointmentType,
333 +
duration: 30,
334 +
},
335 +
}),
336 +
);
337 +
338 +
expect(card.title).toBe(`Consulta · ${label}`);
339 +
});
340 +
341 +
describe("vínculo badge", () => {
342 +
it("is absent with no links", () => {
343 +
expect(
344 +
mapCareActionToHistoryCard(careAction({ type: "APPOINTMENT" })).badge,
345 +
).toBeUndefined();
346 +
});
347 +
348 +
it("uses the singular for a single link", () => {
349 +
expect(
350 +
mapCareActionToHistoryCard(
351 +
careAction({ type: "APPOINTMENT", triggerId: 5 }),
352 +
).badge,
353 +
).toEqual({ label: "1 vínculo", variant: "blue" });
354 +
});
355 +
356 +
it("uses the plural for two links", () => {
357 +
expect(
358 +
mapCareActionToHistoryCard(
359 +
careAction({ type: "APPOINTMENT", triggerId: 5, moodId: 6 }),
360 +
).badge,
361 +
).toEqual({ label: "2 vínculos", variant: "blue" });
362 +
});
363 +
});
364 +
});
365 +
366 +
describe("activity", () => {
367 +
it("uses the activity label and duration", () => {
368 +
const record = careAction({
369 +
id: 30,
370 +
type: "ACTIVITY",
371 +
activity: { id: 1, careActionId: 30, type: "WALK", duration: 30 },
372 +
});
373 +
374 +
expect(mapCareActionToHistoryCard(record)).toStrictEqual({
375 +
id: "care_action-30",
376 +
category: "care_action",
377 +
subtype: "activity",
378 +
timestamp: record.moment.toString(),
379 +
title: "Caminhada",
380 +
summary: "30 min",
381 +
raw: record,
382 +
});
383 +
});
384 +
385 +
it("falls back when there is no duration", () => {
386 +
const card = mapCareActionToHistoryCard(
387 +
careAction({
388 +
type: "ACTIVITY",
389 +
activity: { id: 1, careActionId: 1, type: "YOGA" },
390 +
}),
391 +
);
392 +
393 +
expect(card.summary).toBe("Sem duração registrada");
394 +
});
395 +
396 +
it.each([
397 +
["WALK", "Caminhada"],
398 +
["YOGA", "Yoga"],
399 +
["GYM", "Academia"],
400 +
["MEDITATION", "Meditação"],
401 +
["SOCIAL", "Momento social"],
402 +
["CREATIVE", "Atividade criativa"],
403 +
["OTHER", "Atividade"],
404 +
["unmapped", "Atividade"],
405 +
] as const)("labels the %p activity as %p", (type, label) => {
406 +
const card = mapCareActionToHistoryCard(
407 +
careAction({
408 +
type: "ACTIVITY",
409 +
activity: {
410 +
id: 1,
411 +
careActionId: 1,
412 +
type: type as ActivityType,
413 +
},
414 +
}),
415 +
);
416 +
417 +
expect(card.title).toBe(label);
418 +
});
419 +
});
420 +
421 +
it("falls back for an unknown care-action type", () => {
422 +
const record = careAction({
423 +
id: 40,
424 +
type: "SOMETHING_ELSE" as unknown as CareAction["type"],
425 +
});
426 +
427 +
expect(mapCareActionToHistoryCard(record)).toStrictEqual({
428 +
id: "care_action-40",
429 +
category: "care_action",
430 +
timestamp: record.moment.toString(),
431 +
title: "Ação de cuidado",
432 +
summary: "",
433 +
raw: record,
434 +
});
435 +
});
436 +
});
/dev/null → frontend/tests/utils/history.unit.test.ts
1 +
import {
2 +
filterByCategory,
3 +
groupByDate,
4 +
mergeAndSort,
5 +
} from "@/lib/utils/history";
6 +
import type { HistoryCard, HistoryCategory } from "@/lib/history/types";
7 +
8 +
function card(
9 +
id: string,
10 +
category: HistoryCategory,
11 +
at: Date,
12 +
): HistoryCard {
13 +
return {
14 +
id,
15 +
category,
16 +
timestamp: at.toISOString(),
17 +
title: id,
18 +
summary: "",
19 +
} as HistoryCard;
20 +
}
21 +
22 +
describe("mergeAndSort", () => {
23 +
it("flattens the buckets into one descending list", () => {
24 +
const oldest = card("oldest", "mood", new Date(2025, 0, 1, 10, 0));
25 +
const newest = card("newest", "sleep", new Date(2025, 0, 3, 10, 0));
26 +
const middle = card("middle", "trigger", new Date(2025, 0, 2, 10, 0));
27 +
28 +
const merged = mergeAndSort([[oldest, middle], [newest]]);
29 +
30 +
expect(merged.map((c) => c.id)).toEqual(["newest", "middle", "oldest"]);
31 +
});
32 +
33 +
it("returns an empty list for no buckets", () => {
34 +
expect(mergeAndSort([])).toEqual([]);
35 +
});
36 +
});
37 +
38 +
describe("groupByDate", () => {
39 +
it("groups into fixed, ordered date buckets", () => {
40 +
const morning = card("m1", "mood", new Date(2025, 0, 15, 9, 0));
41 +
const evening = card("s1", "sleep", new Date(2025, 0, 15, 20, 0));
42 +
const previous = card("t1", "trigger", new Date(2025, 0, 14, 12, 0));
43 +
44 +
const grouped = groupByDate([morning, evening, previous]);
45 +
46 +
expect(Object.keys(grouped)).toEqual([
47 +
"quarta-feira, 15 de janeiro",
48 +
"terça-feira, 14 de janeiro",
49 +
]);
50 +
expect(grouped["quarta-feira, 15 de janeiro"].map((c) => c.id)).toEqual([
51 +
"m1",
52 +
"s1",
53 +
]);
54 +
expect(grouped["terça-feira, 14 de janeiro"].map((c) => c.id)).toEqual([
55 +
"t1",
56 +
]);
57 +
});
58 +
59 +
it("returns no buckets for an empty list", () => {
60 +
expect(groupByDate([])).toEqual({});
61 +
});
62 +
});
63 +
64 +
describe("filterByCategory", () => {
65 +
const cards = [
66 +
card("m", "mood", new Date(2025, 0, 1, 10, 0)),
67 +
card("s", "sleep", new Date(2025, 0, 2, 10, 0)),
68 +
card("t", "trigger", new Date(2025, 0, 3, 10, 0)),
69 +
card("c", "care_action", new Date(2025, 0, 4, 10, 0)),
70 +
];
71 +
72 +
it.each([
73 +
["mood", ["m"]],
74 +
["sleep", ["s"]],
75 +
["trigger", ["t"]],
76 +
["care_action", ["c"]],
77 +
] as const)("keeps only the %s cards", (category, ids) => {
78 +
expect(filterByCategory(cards, category).map((c) => c.id)).toEqual(ids);
79 +
});
80 +
81 +
it("keeps everything for 'all'", () => {
82 +
expect(filterByCategory(cards, "all").map((c) => c.id)).toEqual([
83 +
"m",
84 +
"s",
85 +
"t",
86 +
"c",
87 +
]);
88 +
});
89 +
});