api: lift the daily insight math out of its Prisma wrappers
The energy averages, peak and trough, intra-day trend, and yesterday delta, plus the sleep totals, minute difference, and nap count, all ran only after a fetch. Taking the rows the select returns and giving back the metadata and body makes them callable without a database; the processors keep their fetch and upsert, so the stored Insight does not change
Pedro Lucas Porcellis porcellis@eletrotupi.com · 10 days ago
68b32b6167f98c0777e1cc816c4065363f24fba4 Browse filesParents
7ff50aaChanged files
4
Additions
+326
Deletions
-78
api/src/lib/queue/processors/insights/dailyEnergy.ts
1 1
import { prisma } from "@app/lib/prisma";
2 2
import { InsightType, InsightPeriod } from "@prisma/client";
3 3
import { InsightPeriodPayload } from "@app/lib/queue/types";
4 4
import { avg } from "@app/utils/calc";
5 5
import {
6 6
appDayWindow,
7 7
previousAppDay,
8 8
DAY_WINDOW_LABEL,
9 9
} from "@app/lib/queue/processors/insights/utils";
10 10
11 -
export async function processDailyEnergy({
12 -
userId,
13 -
periodStart,
14 -
}: InsightPeriodPayload): Promise<void> {
15 -
const { start, end } = appDayWindow(new Date(periodStart));
16 -
const yesterdayStart = previousAppDay(start);
17 -
18 -
const [moods, yesterdayMoods] = await Promise.all([
19 -
prisma.mood.findMany({
20 -
where: { userId, moment: { gte: start, lt: end } },
21 -
orderBy: { moment: "asc" },
22 -
select: {
23 -
energyLevel: true,
24 -
stressLevel: true,
25 -
anxietyLevel: true,
26 -
selectedMood: true,
27 -
moment: true,
28 -
},
29 -
}),
30 -
prisma.mood.findMany({
31 -
where: { userId, moment: { gte: yesterdayStart, lt: start } },
32 -
select: { energyLevel: true },
33 -
}),
34 -
]);
11 +
type EnergyEntry = {
12 +
energyLevel: number;
13 +
stressLevel: number;
14 +
anxietyLevel: number;
15 +
};
35 16
36 -
if (moods.length === 0) return;
17 +
export type DailyEnergyMetadata = {
18 +
avgEnergy: number;
19 +
avgStress: number;
20 +
avgAnxiety: number;
21 +
peak: number;
22 +
trough: number;
23 +
dayTrend: number | null;
24 +
entryCount: number;
25 +
yesterdayAvg: number | null;
26 +
diffEnergy: number | null;
27 +
windowLabel: string;
28 +
};
37 29
30 +
export function computeDailyEnergy(
31 +
moods: EnergyEntry[],
32 +
yesterdayMoods: { energyLevel: number }[],
33 +
): { metadata: DailyEnergyMetadata; dayTrend: number | null } {
38 34
const avgEnergy = parseFloat(avg(moods.map((m) => m.energyLevel)).toFixed(2));
39 35
const avgStress = parseFloat(avg(moods.map((m) => m.stressLevel)).toFixed(2));
40 36
const avgAnxiety = parseFloat(
41 37
avg(moods.map((m) => m.anxietyLevel)).toFixed(2),
42 38
);
69 65
const diffEnergy =
70 66
yesterdayAvg !== null
71 67
? parseFloat((avgEnergy - yesterdayAvg).toFixed(2))
72 68
: null;
73 69
74 -
const metadata = {
70 +
const metadata: DailyEnergyMetadata = {
75 71
avgEnergy,
76 72
avgStress,
77 73
avgAnxiety,
78 74
peak,
79 75
trough,
82 78
yesterdayAvg,
83 79
diffEnergy,
84 80
windowLabel: DAY_WINDOW_LABEL,
85 81
};
86 82
83 +
return { metadata, dayTrend };
84 +
}
85 +
86 +
export function dailyEnergyBody(
87 +
avgEnergy: number,
88 +
dayTrend: number | null,
89 +
): string {
90 +
const level = avgEnergy >= 7 ? "alta" : avgEnergy >= 4 ? "moderada" : "baixa";
91 +
if (dayTrend === null) return `Energia ${level} hoje.`;
92 +
if (dayTrend > 10)
93 +
return `Energia ${level} hoje, com melhora ao longo do dia.`;
94 +
if (dayTrend < -10)
95 +
return `Energia ${level} hoje, com queda ao longo do dia.`;
96 +
return `Energia ${level} e estável ao longo do dia.`;
97 +
}
98 +
99 +
export async function processDailyEnergy({
100 +
userId,
101 +
periodStart,
102 +
}: InsightPeriodPayload): Promise<void> {
103 +
const { start, end } = appDayWindow(new Date(periodStart));
104 +
const yesterdayStart = previousAppDay(start);
105 +
106 +
const [moods, yesterdayMoods] = await Promise.all([
107 +
prisma.mood.findMany({
108 +
where: { userId, moment: { gte: start, lt: end } },
109 +
orderBy: { moment: "asc" },
110 +
select: {
111 +
energyLevel: true,
112 +
stressLevel: true,
113 +
anxietyLevel: true,
114 +
selectedMood: true,
115 +
moment: true,
116 +
},
117 +
}),
118 +
prisma.mood.findMany({
119 +
where: { userId, moment: { gte: yesterdayStart, lt: start } },
120 +
select: { energyLevel: true },
121 +
}),
122 +
]);
123 +
124 +
if (moods.length === 0) return;
125 +
126 +
const { metadata, dayTrend } = computeDailyEnergy(moods, yesterdayMoods);
127 +
87 128
await prisma.insight.upsert({
88 129
where: {
89 130
userId_type_periodStart: {
90 131
userId,
91 132
type: InsightType.DAILY_ENERGY,
92 133
periodStart: start,
93 134
},
94 135
},
95 136
update: {
96 -
body: buildBody(avgEnergy, dayTrend),
137 +
body: dailyEnergyBody(metadata.avgEnergy, dayTrend),
97 138
metadata,
98 139
generatedAt: new Date(),
99 140
},
100 141
create: {
101 142
userId,
102 143
type: InsightType.DAILY_ENERGY,
103 144
period: InsightPeriod.DAILY,
104 145
title: "Energia do Dia",
105 -
body: buildBody(avgEnergy, dayTrend),
146 +
body: dailyEnergyBody(metadata.avgEnergy, dayTrend),
106 147
metadata,
107 148
periodStart: start,
108 149
periodEnd: end,
109 150
},
110 151
});
111 152
}
112 -
113 -
function buildBody(avgEnergy: number, dayTrend: number | null): string {
114 -
const level = avgEnergy >= 7 ? "alta" : avgEnergy >= 4 ? "moderada" : "baixa";
115 -
if (dayTrend === null) return `Energia ${level} hoje.`;
116 -
if (dayTrend > 10)
117 -
return `Energia ${level} hoje, com melhora ao longo do dia.`;
118 -
if (dayTrend < -10)
119 -
return `Energia ${level} hoje, com queda ao longo do dia.`;
120 -
return `Energia ${level} e estável ao longo do dia.`;
121 -
}
api/src/lib/queue/processors/insights/dailySleep.ts
1 1
import { prisma } from "@app/lib/prisma";
2 2
import { InsightType, InsightPeriod } from "@prisma/client";
3 3
import { InsightPeriodPayload } from "@app/lib/queue/types";
4 4
import { logger } from "@app/lib/logger";
5 5
import { formatHours, formatDiff } from "@app/utils/calc";
7 7
appDayWindow,
8 8
previousAppDay,
9 9
DAY_WINDOW_LABEL,
10 10
} from "@app/lib/queue/processors/insights/utils";
11 11
12 -
export async function processDailySleep({
13 -
userId,
14 -
periodStart,
15 -
}: InsightPeriodPayload): Promise<void> {
16 -
const { start: today, end: tomorrow } = appDayWindow(new Date(periodStart));
17 -
const yesterday = previousAppDay(today);
18 -
19 -
const [todayRecords, yesterdayRecords] = await Promise.all([
20 -
prisma.sleepRecord.findMany({
21 -
where: { userId, date: { gte: today, lt: tomorrow } },
22 -
select: { average: true },
23 -
}),
24 -
prisma.sleepRecord.findMany({
25 -
where: { userId, date: { gte: yesterday, lt: today } },
26 -
select: { average: true },
27 -
}),
28 -
]);
29 -
30 -
logger.debug(
31 -
{ todayRecords, yesterdayRecords },
32 -
"[insights] daily sleep records",
33 -
);
12 +
type SleepRecordEntry = { average: number };
34 13
35 -
if (todayRecords.length === 0) return;
14 +
export type DailySleepMetadata = {
15 +
totalHours: number;
16 +
diffMinutes: number | null;
17 +
diffStr: string;
18 +
sessionCount: number;
19 +
napStr: string;
20 +
hadNaps: boolean;
21 +
windowLabel: string;
22 +
};
36 23
24 +
export function computeDailySleep(
25 +
todayRecords: SleepRecordEntry[],
26 +
yesterdayRecords: SleepRecordEntry[],
27 +
): { metadata: DailySleepMetadata } {
37 28
// average is stored in hours
38 29
// sum across all records for the day
39 -
const totalToday = todayRecords.reduce(
40 -
(sum: number, r: { average: number }) => sum + r.average,
41 -
0,
42 -
);
30 +
const totalToday = todayRecords.reduce((sum, r) => sum + r.average, 0);
43 31
const totalYesterday =
44 32
yesterdayRecords.length > 0
45 -
? yesterdayRecords.reduce(
46 -
(sum: number, r: { average: number }) => sum + r.average,
47 -
0,
48 -
)
33 +
? yesterdayRecords.reduce((sum, r) => sum + r.average, 0)
49 34
: null;
50 35
51 36
const diffMinutes =
52 37
totalYesterday !== null
53 38
? Math.round((totalToday - totalYesterday) * 60)
63 48
const napStr =
64 49
sessionCount > 1
65 50
? `incluindo ${sessionCount - 1} cochilo${sessionCount > 2 ? "s" : ""}`
66 51
: "";
67 52
68 -
const metadata = {
53 +
const metadata: DailySleepMetadata = {
69 54
totalHours: totalToday,
70 55
diffMinutes,
71 56
diffStr,
72 57
sessionCount,
73 58
napStr,
74 59
hadNaps: sessionCount > 1,
75 60
windowLabel: DAY_WINDOW_LABEL,
76 61
};
77 62
63 +
return { metadata };
64 +
}
65 +
66 +
export function dailySleepBody(total: number): string {
67 +
const totalStr = formatHours(total);
68 +
69 +
return `${totalStr} de sono hoje`;
70 +
}
71 +
72 +
export async function processDailySleep({
73 +
userId,
74 +
periodStart,
75 +
}: InsightPeriodPayload): Promise<void> {
76 +
const { start: today, end: tomorrow } = appDayWindow(new Date(periodStart));
77 +
const yesterday = previousAppDay(today);
78 +
79 +
const [todayRecords, yesterdayRecords] = await Promise.all([
80 +
prisma.sleepRecord.findMany({
81 +
where: { userId, date: { gte: today, lt: tomorrow } },
82 +
select: { average: true },
83 +
}),
84 +
prisma.sleepRecord.findMany({
85 +
where: { userId, date: { gte: yesterday, lt: today } },
86 +
select: { average: true },
87 +
}),
88 +
]);
89 +
90 +
logger.debug(
91 +
{ todayRecords, yesterdayRecords },
92 +
"[insights] daily sleep records",
93 +
);
94 +
95 +
if (todayRecords.length === 0) return;
96 +
97 +
const { metadata } = computeDailySleep(todayRecords, yesterdayRecords);
98 +
78 99
await prisma.insight.upsert({
79 100
where: {
80 101
userId_type_periodStart: {
81 102
userId,
82 103
type: InsightType.DAILY_SLEEP,
83 104
periodStart: today,
84 105
},
85 106
},
86 107
update: {
87 -
body: buildBody(totalToday),
108 +
body: dailySleepBody(metadata.totalHours),
88 109
metadata,
89 110
generatedAt: new Date(),
90 111
},
91 112
create: {
92 113
userId,
93 114
type: InsightType.DAILY_SLEEP,
94 115
period: InsightPeriod.DAILY,
95 116
title: "Sono",
96 -
body: buildBody(totalToday),
117 +
body: dailySleepBody(metadata.totalHours),
97 118
metadata,
98 119
periodStart: today,
99 120
periodEnd: tomorrow,
100 121
},
101 122
});
102 123
}
103 -
104 -
function buildBody(total: number): string {
105 -
const totalStr = formatHours(total);
106 -
107 -
return `${totalStr} de sono hoje`;
108 -
}
/dev/null → api/tests/insights/daily-energy.unit.test.ts
1 +
import { describe, it, expect } from 'vitest'
2 +
import {
3 +
computeDailyEnergy,
4 +
dailyEnergyBody,
5 +
} from '@app/lib/queue/processors/insights/dailyEnergy'
6 +
7 +
const mood = (energyLevel: number, stressLevel: number, anxietyLevel: number) => ({
8 +
energyLevel,
9 +
stressLevel,
10 +
anxietyLevel,
11 +
})
12 +
13 +
describe('computeDailyEnergy', () => {
14 +
it('averages the levels, finds the peaks, and trends the day', () => {
15 +
const { metadata, dayTrend } = computeDailyEnergy(
16 +
[mood(8, 2, 1), mood(4, 4, 3), mood(6, 6, 5)],
17 +
[],
18 +
)
19 +
20 +
expect(metadata).toEqual({
21 +
avgEnergy: 6,
22 +
avgStress: 4,
23 +
avgAnxiety: 3,
24 +
peak: 8,
25 +
trough: 4,
26 +
dayTrend: -37.5,
27 +
entryCount: 3,
28 +
yesterdayAvg: null,
29 +
diffEnergy: null,
30 +
windowLabel: 'hoje',
31 +
})
32 +
expect(dayTrend).toBe(-37.5)
33 +
})
34 +
35 +
it('rounds the averages to two decimals', () => {
36 +
const { metadata } = computeDailyEnergy(
37 +
[mood(7, 7, 7), mood(8, 8, 8), mood(8, 8, 8)],
38 +
[],
39 +
)
40 +
41 +
expect(metadata).toMatchObject({
42 +
avgEnergy: 7.67,
43 +
avgStress: 7.67,
44 +
avgAnxiety: 7.67,
45 +
peak: 8,
46 +
trough: 7,
47 +
})
48 +
})
49 +
50 +
it('has no day trend with a single entry', () => {
51 +
const { metadata, dayTrend } = computeDailyEnergy([mood(5, 1, 1)], [])
52 +
53 +
expect(dayTrend).toBeNull()
54 +
expect(metadata).toMatchObject({
55 +
dayTrend: null,
56 +
entryCount: 1,
57 +
peak: 5,
58 +
trough: 5,
59 +
})
60 +
})
61 +
62 +
it('subtracts yesterday from the rounded today average, either way', () => {
63 +
const up = computeDailyEnergy(
64 +
[mood(6, 1, 1), mood(6, 1, 1)],
65 +
[{ energyLevel: 4 }, { energyLevel: 4 }],
66 +
).metadata
67 +
68 +
const down = computeDailyEnergy(
69 +
[mood(4, 1, 1), mood(4, 1, 1)],
70 +
[{ energyLevel: 9 }, { energyLevel: 9 }],
71 +
).metadata
72 +
73 +
expect(up).toMatchObject({
74 +
avgEnergy: 6,
75 +
yesterdayAvg: 4,
76 +
diffEnergy: 2,
77 +
dayTrend: 0,
78 +
})
79 +
expect(down).toMatchObject({
80 +
avgEnergy: 4,
81 +
yesterdayAvg: 9,
82 +
diffEnergy: -5,
83 +
})
84 +
})
85 +
})
86 +
87 +
describe('dailyEnergyBody', () => {
88 +
it('picks the level from the average energy', () => {
89 +
expect(dailyEnergyBody(7, null)).toBe('Energia alta hoje.')
90 +
expect(dailyEnergyBody(4, null)).toBe('Energia moderada hoje.')
91 +
expect(dailyEnergyBody(3, null)).toBe('Energia baixa hoje.')
92 +
})
93 +
94 +
it('adds the day trend when there is one', () => {
95 +
expect(dailyEnergyBody(5, 20)).toBe(
96 +
'Energia moderada hoje, com melhora ao longo do dia.',
97 +
)
98 +
expect(dailyEnergyBody(5, -20)).toBe(
99 +
'Energia moderada hoje, com queda ao longo do dia.',
100 +
)
101 +
expect(dailyEnergyBody(5, 0)).toBe(
102 +
'Energia moderada e estável ao longo do dia.',
103 +
)
104 +
})
105 +
106 +
it('treats a trend of exactly ten as stable', () => {
107 +
expect(dailyEnergyBody(5, 10)).toBe(
108 +
'Energia moderada e estável ao longo do dia.',
109 +
)
110 +
expect(dailyEnergyBody(5, -10)).toBe(
111 +
'Energia moderada e estável ao longo do dia.',
112 +
)
113 +
expect(dailyEnergyBody(5, 11)).toBe(
114 +
'Energia moderada hoje, com melhora ao longo do dia.',
115 +
)
116 +
expect(dailyEnergyBody(5, -11)).toBe(
117 +
'Energia moderada hoje, com queda ao longo do dia.',
118 +
)
119 +
})
120 +
})
/dev/null → api/tests/insights/daily-sleep.unit.test.ts
1 +
import { describe, it, expect } from 'vitest'
2 +
import {
3 +
computeDailySleep,
4 +
dailySleepBody,
5 +
} from '@app/lib/queue/processors/insights/dailySleep'
6 +
7 +
const record = (average: number) => ({ average })
8 +
9 +
describe('computeDailySleep', () => {
10 +
it('sums today and compares it against yesterday', () => {
11 +
const { metadata } = computeDailySleep([record(8)], [record(6)])
12 +
13 +
expect(metadata).toEqual({
14 +
totalHours: 8,
15 +
diffMinutes: 120,
16 +
diffStr: '+2h que ontem',
17 +
sessionCount: 1,
18 +
napStr: '',
19 +
hadNaps: false,
20 +
windowLabel: 'hoje',
21 +
})
22 +
})
23 +
24 +
it('omits the difference when nothing was logged yesterday', () => {
25 +
const { metadata } = computeDailySleep([record(6)], [])
26 +
27 +
expect(metadata).toMatchObject({
28 +
totalHours: 6,
29 +
diffMinutes: null,
30 +
diffStr: '',
31 +
})
32 +
})
33 +
34 +
it('subtracts yesterday from today when yesterday was longer', () => {
35 +
const { metadata } = computeDailySleep([record(4)], [record(9)])
36 +
37 +
expect(metadata).toMatchObject({
38 +
totalHours: 4,
39 +
diffMinutes: -300,
40 +
diffStr: '-5h que ontem',
41 +
})
42 +
})
43 +
44 +
it('names the extra sessions as naps, pluralized past two', () => {
45 +
const oneNap = computeDailySleep([record(5), record(2)], []).metadata
46 +
const twoNaps = computeDailySleep(
47 +
[record(5), record(2), record(1)],
48 +
[],
49 +
).metadata
50 +
51 +
expect(oneNap).toMatchObject({
52 +
totalHours: 7,
53 +
sessionCount: 2,
54 +
napStr: 'incluindo 1 cochilo',
55 +
hadNaps: true,
56 +
})
57 +
expect(twoNaps).toMatchObject({
58 +
totalHours: 8,
59 +
sessionCount: 3,
60 +
napStr: 'incluindo 2 cochilos',
61 +
})
62 +
})
63 +
64 +
it('drops the difference string under five minutes', () => {
65 +
const under = computeDailySleep([record(6)], [record(6.05)]).metadata
66 +
const at = computeDailySleep([record(6)], [record(5.9)]).metadata
67 +
68 +
expect(under).toMatchObject({ diffMinutes: -3, diffStr: '' })
69 +
expect(at).toMatchObject({ diffMinutes: 6, diffStr: '+6min que ontem' })
70 +
})
71 +
})
72 +
73 +
describe('dailySleepBody', () => {
74 +
it('formats a whole hour and a remainder', () => {
75 +
expect(dailySleepBody(8)).toBe('8h de sono hoje')
76 +
expect(dailySleepBody(7.5)).toBe('7h 30min de sono hoje')
77 +
})
78 +
79 +
it('renders a day with nothing logged as zero hours', () => {
80 +
expect(dailySleepBody(0)).toBe('0h de sono hoje')
81 +
})
82 +
})