eletrotupi / tcc/ commit / 074b881

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 files
Parents 68b32b6
Changed 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 +
})