api: lift the report aggregators out of the data builder
The mood distribution, the daily-mood rollup, the top components, and the check-in projection were inlined behind the builder's Prisma fetch, so none of the report's analysis had a test. Taking the plain rows the builder already loads and returning the same arrays makes them callable without a database; the builder keeps its fetch, so the produced report data is unchanged
Pedro Lucas Porcellis porcellis@eletrotupi.com · 10 days ago
074b881232764e3f20a6308cce0c2027907a0fda Browse filesParents
68b32b6Changed files
2
Additions
+240
Deletions
-37
api/src/reports/report.data.ts
1 1
import { eachDayOfInterval, format } from 'date-fns'
2 2
import { prisma } from '@app/lib/prisma'
3 3
import { logger } from '@app/lib/logger'
4 4
import {
5 5
BaseMoodOption,
36 36
const MOOD_ORDER: BaseMoodOption[] = [
37 37
'GREAT', 'GOOD', 'NEUTRAL', 'SAD', 'ANGRY',
38 38
]
39 39
40 40
41 -
export async function buildReportData(
42 -
userId: number,
43 -
start: Date,
44 -
end: Date,
45 -
): Promise<ReportData> {
46 -
const [user, moods] = await Promise.all([
47 -
prisma.user.findUniqueOrThrow({
48 -
where: { id: userId },
49 -
select: { firstName: true, lastName: true },
50 -
}),
51 -
prisma.mood.findMany({
52 -
where: {
53 -
userId,
54 -
moment: { gte: start, lte: end },
55 -
},
56 -
include: { moodComponents: true },
57 -
orderBy: { moment: 'desc' },
58 -
}),
59 -
])
60 -
61 -
const total = moods.length
62 -
63 -
logger.info(
64 -
{ count: moods.length, start, end },
65 -
'[report] found mood entries',
66 -
)
67 -
41 +
export function buildMoodDistribution(
42 +
moods: { selectedMood: BaseMoodOption }[],
43 +
total: number,
44 +
): ReportData['moodDistribution'] {
68 45
const moodCounts = Object.fromEntries(
69 46
MOOD_ORDER.map(m => [m, 0]),
70 47
) as Record<BaseMoodOption, number>
71 48
72 49
for (const m of moods) moodCounts[m.selectedMood]++
73 50
74 -
const moodDistribution = MOOD_ORDER.map(mood => ({
51 +
return MOOD_ORDER.map(mood => ({
75 52
mood,
76 53
count: moodCounts[mood],
77 54
pct: total === 0 ? 0 : Math.round((moodCounts[mood] / total) * 1000) / 10,
78 55
}))
56 +
}
79 57
80 -
const byDate = new Map<string, typeof moods[number][]>()
58 +
// One entry per day in the interval; the latest Mood Entry of the day wins
59 +
export function buildDailyMoods(
60 +
moods: { moment: Date; selectedMood: BaseMoodOption }[],
61 +
days: Date[],
62 +
): ReportData['dailyMoods'] {
63 +
const byDate = new Map<
64 +
string,
65 +
{ moment: Date; selectedMood: BaseMoodOption }[]
66 +
>()
81 67
for (const m of moods) {
82 68
const key = format(m.moment, 'yyyy-MM-dd')
83 69
const group = byDate.get(key) ?? []
84 70
group.push(m)
85 71
byDate.set(key, group)
86 72
}
87 73
88 -
const days = eachDayOfInterval({ start, end })
89 -
const dailyMoods = days.map(day => {
74 +
return days.map(day => {
90 75
const key = format(day, 'yyyy-MM-dd')
91 76
const group = byDate.get(key)
92 77
if (!group || group.length === 0) return { date: key, mood: null }
93 78
const last = group.reduce((a, b) =>
94 79
b.moment > a.moment ? b : a,
95 80
)
96 81
return { date: key, mood: last.selectedMood }
97 82
})
83 +
}
98 84
85 +
export function buildTopComponents(
86 +
moods: {
87 +
moodComponents: {
88 +
component: MoodComponentOption
89 +
intensity: IntensityLevel
90 +
}[]
91 +
}[],
92 +
): ReportData['topComponents'] {
99 93
const componentGroups = new Map<
100 94
MoodComponentOption,
101 95
IntensityLevel[]
102 96
>()
103 97
for (const m of moods) {
106 100
arr.push(c.intensity)
107 101
componentGroups.set(c.component, arr)
108 102
}
109 103
}
110 104
111 -
const topComponents = Array.from(componentGroups.entries())
105 +
return Array.from(componentGroups.entries())
112 106
.map(([component, intensities]) => {
113 107
const freq = new Map<IntensityLevel, number>()
114 108
for (const i of intensities) freq.set(i, (freq.get(i) ?? 0) + 1)
115 109
const avgIntensity = Array.from(freq.entries()).reduce((a, b) =>
116 110
b[1] > a[1] ? b : a,
117 111
)[0]
118 112
return { component, count: intensities.length, avgIntensity }
119 113
})
120 114
.sort((a, b) => b.count - a.count)
121 115
.slice(0, 10)
116 +
}
122 117
123 -
const checkinLog = moods.slice(0, 50).map(m => ({
118 +
export function buildCheckinLog(
119 +
moods: {
120 +
moment: Date
121 +
selectedMood: BaseMoodOption
122 +
anxietyLevel: number
123 +
stressLevel: number
124 +
energyLevel: number
125 +
annotation: string | null
126 +
}[],
127 +
): ReportData['checkinLog'] {
128 +
return moods.slice(0, 50).map(m => ({
124 129
moment: m.moment,
125 130
mood: m.selectedMood,
126 131
anxietyLevel: m.anxietyLevel,
127 132
stressLevel: m.stressLevel,
128 133
energyLevel: m.energyLevel,
129 134
annotation: m.annotation,
130 135
}))
136 +
}
137 +
138 +
export async function buildReportData(
139 +
userId: number,
140 +
start: Date,
141 +
end: Date,
142 +
): Promise<ReportData> {
143 +
const [user, moods] = await Promise.all([
144 +
prisma.user.findUniqueOrThrow({
145 +
where: { id: userId },
146 +
select: { firstName: true, lastName: true },
147 +
}),
148 +
prisma.mood.findMany({
149 +
where: {
150 +
userId,
151 +
moment: { gte: start, lte: end },
152 +
},
153 +
include: { moodComponents: true },
154 +
orderBy: { moment: 'desc' },
155 +
}),
156 +
])
157 +
158 +
const total = moods.length
159 +
160 +
logger.info(
161 +
{ count: moods.length, start, end },
162 +
'[report] found mood entries',
163 +
)
164 +
165 +
const days = eachDayOfInterval({ start, end })
131 166
132 167
return {
133 168
user,
134 169
periodStart: start,
135 170
periodEnd: end,
136 171
totalCheckins: total,
137 -
moodDistribution,
138 -
dailyMoods,
139 -
topComponents,
140 -
checkinLog,
172 +
moodDistribution: buildMoodDistribution(moods, total),
173 +
dailyMoods: buildDailyMoods(moods, days),
174 +
topComponents: buildTopComponents(moods),
175 +
checkinLog: buildCheckinLog(moods),
141 176
}
142 177
}
/dev/null → api/tests/reports/report-data.unit.test.ts
1 +
import { describe, it, expect } from 'vitest'
2 +
import {
3 +
BaseMoodOption,
4 +
MoodComponentOption,
5 +
IntensityLevel,
6 +
} from '@prisma/client'
7 +
import {
8 +
buildMoodDistribution,
9 +
buildDailyMoods,
10 +
buildTopComponents,
11 +
buildCheckinLog,
12 +
} from '@app/reports/report.data'
13 +
14 +
const mood = (selectedMood: BaseMoodOption) => ({ selectedMood })
15 +
16 +
// Local-noon instants keep the yyyy-MM-dd grouping stable across timezones
17 +
const at = (year: number, month: number, day: number, hour = 12) =>
18 +
new Date(year, month - 1, day, hour)
19 +
20 +
describe('buildMoodDistribution', () => {
21 +
it('counts each base mood and computes its share of the period', () => {
22 +
const distribution = buildMoodDistribution(
23 +
[mood('GREAT'), mood('GREAT'), mood('GOOD')],
24 +
3,
25 +
)
26 +
27 +
expect(distribution).toEqual([
28 +
{ mood: 'GREAT', count: 2, pct: 66.7 },
29 +
{ mood: 'GOOD', count: 1, pct: 33.3 },
30 +
{ mood: 'NEUTRAL', count: 0, pct: 0 },
31 +
{ mood: 'SAD', count: 0, pct: 0 },
32 +
{ mood: 'ANGRY', count: 0, pct: 0 },
33 +
])
34 +
})
35 +
36 +
it('reports all zeros for an empty period', () => {
37 +
const order: BaseMoodOption[] = [
38 +
'GREAT',
39 +
'GOOD',
40 +
'NEUTRAL',
41 +
'SAD',
42 +
'ANGRY',
43 +
]
44 +
45 +
expect(buildMoodDistribution([], 0)).toEqual(
46 +
order.map(mood => ({ mood, count: 0, pct: 0 })),
47 +
)
48 +
})
49 +
})
50 +
51 +
describe('buildDailyMoods', () => {
52 +
it('keeps the latest Mood Entry of each day, whatever the order', () => {
53 +
const daily = buildDailyMoods(
54 +
[
55 +
{ moment: at(2026, 10, 17, 20), selectedMood: 'GREAT' },
56 +
{ moment: at(2026, 10, 17, 8), selectedMood: 'SAD' },
57 +
],
58 +
[at(2026, 10, 17)],
59 +
)
60 +
61 +
expect(daily).toEqual([{ date: '2026-10-17', mood: 'GREAT' }])
62 +
})
63 +
64 +
it('yields a null mood for a day with no entry', () => {
65 +
const daily = buildDailyMoods(
66 +
[{ moment: at(2026, 10, 17, 9), selectedMood: 'GOOD' }],
67 +
[at(2026, 10, 16), at(2026, 10, 17), at(2026, 10, 18)],
68 +
)
69 +
70 +
expect(daily).toEqual([
71 +
{ date: '2026-10-16', mood: null },
72 +
{ date: '2026-10-17', mood: 'GOOD' },
73 +
{ date: '2026-10-18', mood: null },
74 +
])
75 +
})
76 +
})
77 +
78 +
describe('buildTopComponents', () => {
79 +
it('counts each component and keeps the most frequent intensity', () => {
80 +
const components = buildTopComponents([
81 +
{
82 +
moodComponents: [
83 +
{ component: 'JOY', intensity: 'LIGHT' },
84 +
{ component: 'CALM', intensity: 'HIGH' },
85 +
],
86 +
},
87 +
{
88 +
moodComponents: [
89 +
{ component: 'JOY', intensity: 'LIGHT' },
90 +
{ component: 'JOY', intensity: 'HIGH' },
91 +
],
92 +
},
93 +
])
94 +
95 +
expect(components).toEqual([
96 +
{ component: 'JOY', count: 3, avgIntensity: 'LIGHT' },
97 +
{ component: 'CALM', count: 1, avgIntensity: 'HIGH' },
98 +
])
99 +
})
100 +
101 +
it('keeps only the ten most frequent components', () => {
102 +
const components: MoodComponentOption[] = [
103 +
'JOY', 'ANGER', 'SADNESS', 'FEAR', 'GUILT', 'FRUSTRATION',
104 +
'CALM', 'MOTIVATED', 'TIREDNESS', 'GRATITUDE', 'FOCUS',
105 +
]
106 +
107 +
const moods = components.flatMap((component, i) =>
108 +
Array.from({ length: 11 - i }, () => ({
109 +
moodComponents: [{ component, intensity: 'LIGHT' as IntensityLevel }],
110 +
})),
111 +
)
112 +
113 +
const top = buildTopComponents(moods)
114 +
115 +
expect(top).toHaveLength(10)
116 +
expect(top[0]).toEqual({
117 +
component: 'JOY',
118 +
count: 11,
119 +
avgIntensity: 'LIGHT',
120 +
})
121 +
expect(top.map(c => c.component)).not.toContain('FOCUS')
122 +
})
123 +
})
124 +
125 +
describe('buildCheckinLog', () => {
126 +
it('projects the log fields and renames the mood', () => {
127 +
const moment = at(2026, 10, 17, 8)
128 +
129 +
const log = buildCheckinLog([
130 +
{
131 +
moment,
132 +
selectedMood: 'GOOD',
133 +
anxietyLevel: 2,
134 +
stressLevel: 3,
135 +
energyLevel: 7,
136 +
annotation: 'manhã calma',
137 +
},
138 +
])
139 +
140 +
expect(log).toEqual([
141 +
{
142 +
moment,
143 +
mood: 'GOOD',
144 +
anxietyLevel: 2,
145 +
stressLevel: 3,
146 +
energyLevel: 7,
147 +
annotation: 'manhã calma',
148 +
},
149 +
])
150 +
})
151 +
152 +
it('keeps at most the fifty most recent entries', () => {
153 +
const entry = (i: number) => ({
154 +
moment: at(2026, 10, 1, 0),
155 +
selectedMood: 'GOOD' as BaseMoodOption,
156 +
anxietyLevel: 1,
157 +
stressLevel: 1,
158 +
energyLevel: 1,
159 +
annotation: `n${i}`,
160 +
})
161 +
162 +
const log = buildCheckinLog(Array.from({ length: 51 }, (_, i) => entry(i)))
163 +
164 +
expect(log).toHaveLength(50)
165 +
expect(log[0].annotation).toBe('n0')
166 +
expect(log[49].annotation).toBe('n49')
167 +
})
168 +
})