api/insights: daily insights carry a comparison and window label
DAILY_ENERGY now exposes yesterday's average and a signed delta against today, null when there is no yesterday data, so the energy card can say how today compares without inventing a zero. Both daily processors also author a pt-BR window label, which lets the cards render the period instead of leaning on the stored sentence
Pedro Lucas Porcellis porcellis@eletrotupi.com · 12 days ago
de0605fcb0d1980cedfafe4830a7aeaef31de5dc Browse filesParents
269c105Changed files
4
Additions
+193
Deletions
-49
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 -
import { appDayWindow } from "@app/lib/queue/processors/insights/utils";
5 +
import {
6 +
appDayWindow,
7 +
previousAppDay,
8 +
DAY_WINDOW_LABEL,
9 +
} from "@app/lib/queue/processors/insights/utils";
6 10
7 11
export async function processDailyEnergy({
8 12
userId,
9 13
periodStart,
10 14
}: InsightPeriodPayload): Promise<void> {
11 15
const { start, end } = appDayWindow(new Date(periodStart));
16 +
const yesterdayStart = previousAppDay(start);
12 17
13 -
const moods = await prisma.mood.findMany({
14 -
where: { userId, moment: { gte: start, lt: end } },
15 -
orderBy: { moment: "asc" },
16 -
select: {
17 -
energyLevel: true,
18 -
stressLevel: true,
19 -
anxietyLevel: true,
20 -
selectedMood: true,
21 -
moment: true,
22 -
},
23 -
});
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 +
]);
24 35
25 36
if (moods.length === 0) return;
26 37
27 38
const avgEnergy = parseFloat(avg(moods.map((m) => m.energyLevel)).toFixed(2));
28 39
const avgStress = parseFloat(avg(moods.map((m) => m.stressLevel)).toFixed(2));
46 57
const dayTrend =
47 58
morningAvg && eveningAvg
48 59
? parseFloat((((eveningAvg - morningAvg) / morningAvg) * 100).toFixed(1))
49 60
: null;
50 61
62 +
const yesterdayAvg =
63 +
yesterdayMoods.length > 0
64 +
? parseFloat(avg(yesterdayMoods.map((m) => m.energyLevel)).toFixed(2))
65 +
: null;
66 +
67 +
// Positive means more energy than yesterday; null when there is nothing to
68 +
// compare against
69 +
const diffEnergy =
70 +
yesterdayAvg !== null
71 +
? parseFloat((avgEnergy - yesterdayAvg).toFixed(2))
72 +
: null;
73 +
74 +
const metadata = {
75 +
avgEnergy,
76 +
avgStress,
77 +
avgAnxiety,
78 +
peak,
79 +
trough,
80 +
dayTrend,
81 +
entryCount: moods.length,
82 +
yesterdayAvg,
83 +
diffEnergy,
84 +
windowLabel: DAY_WINDOW_LABEL,
85 +
};
86 +
51 87
await prisma.insight.upsert({
52 88
where: {
53 89
userId_type_periodStart: {
54 90
userId,
55 91
type: InsightType.DAILY_ENERGY,
56 92
periodStart: start,
57 93
},
58 94
},
59 95
update: {
60 96
body: buildBody(avgEnergy, dayTrend),
61 -
metadata: {
62 -
avgEnergy,
63 -
avgStress,
64 -
avgAnxiety,
65 -
peak,
66 -
trough,
67 -
dayTrend,
68 -
entryCount: moods.length,
69 -
},
97 +
metadata,
70 98
generatedAt: new Date(),
71 99
},
72 100
create: {
73 101
userId,
74 102
type: InsightType.DAILY_ENERGY,
75 103
period: InsightPeriod.DAILY,
76 104
title: "Energia do Dia",
77 105
body: buildBody(avgEnergy, dayTrend),
78 -
metadata: {
79 -
avgEnergy,
80 -
avgStress,
81 -
avgAnxiety,
82 -
peak,
83 -
trough,
84 -
dayTrend,
85 -
entryCount: moods.length,
86 -
},
106 +
metadata,
87 107
periodStart: start,
88 108
periodEnd: end,
89 109
},
90 110
});
91 111
}
97 117
return `Energia ${level} hoje, com melhora ao longo do dia.`;
98 118
if (dayTrend < -10)
99 119
return `Energia ${level} hoje, com queda ao longo do dia.`;
100 120
return `Energia ${level} e estável ao longo do dia.`;
101 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";
6 -
import { appDayWindow } from "@app/lib/queue/processors/insights/utils";
6 +
import {
7 +
appDayWindow,
8 +
previousAppDay,
9 +
DAY_WINDOW_LABEL,
10 +
} from "@app/lib/queue/processors/insights/utils";
7 11
8 12
export async function processDailySleep({
9 13
userId,
10 14
periodStart,
11 15
}: InsightPeriodPayload): Promise<void> {
12 16
const { start: today, end: tomorrow } = appDayWindow(new Date(periodStart));
13 -
const { start: yesterday } = appDayWindow(new Date(today.getTime() - 1));
17 +
const yesterday = previousAppDay(today);
14 18
15 19
const [todayRecords, yesterdayRecords] = await Promise.all([
16 20
prisma.sleepRecord.findMany({
17 21
where: { userId, date: { gte: today, lt: tomorrow } },
18 22
select: { average: true },
59 63
const napStr =
60 64
sessionCount > 1
61 65
? `incluindo ${sessionCount - 1} cochilo${sessionCount > 2 ? "s" : ""}`
62 66
: "";
63 67
68 +
const metadata = {
69 +
totalHours: totalToday,
70 +
diffMinutes,
71 +
diffStr,
72 +
sessionCount,
73 +
napStr,
74 +
hadNaps: sessionCount > 1,
75 +
windowLabel: DAY_WINDOW_LABEL,
76 +
};
77 +
64 78
await prisma.insight.upsert({
65 79
where: {
66 80
userId_type_periodStart: {
67 81
userId,
68 82
type: InsightType.DAILY_SLEEP,
69 83
periodStart: today,
70 84
},
71 85
},
72 86
update: {
73 87
body: buildBody(totalToday),
74 -
metadata: {
75 -
totalHours: totalToday,
76 -
diffMinutes,
77 -
diffStr,
78 -
sessionCount,
79 -
napStr,
80 -
hadNaps: sessionCount > 1,
81 -
},
88 +
metadata,
82 89
generatedAt: new Date(),
83 90
},
84 91
create: {
85 92
userId,
86 93
type: InsightType.DAILY_SLEEP,
87 94
period: InsightPeriod.DAILY,
88 95
title: "Sono",
89 96
body: buildBody(totalToday),
90 -
metadata: {
91 -
totalHours: totalToday,
92 -
diffMinutes,
93 -
sessionCount,
94 -
diffStr,
95 -
napStr,
96 -
hadNaps: sessionCount > 1,
97 -
},
97 +
metadata,
98 98
periodStart: today,
99 99
periodEnd: tomorrow,
100 100
},
101 101
});
102 102
}
104 104
function buildBody(total: number): string {
105 105
const totalStr = formatHours(total);
106 106
107 107
return `${totalStr} de sono hoje`;
108 108
}
api/src/lib/queue/processors/insights/utils.ts
1 1
import { tz } from "@date-fns/tz";
2 -
import { addDays, startOfDay } from "date-fns";
2 +
import { addDays, startOfDay, subDays } from "date-fns";
3 3
4 4
export function rollingPeriod(days = 7) {
5 5
const periodEnd = new Date();
6 6
const periodStart = new Date();
7 7
periodStart.setDate(periodEnd.getDate() - days);
12 12
};
13 13
}
14 14
15 15
const APP_TIMEZONE = tz("America/Sao_Paulo");
16 16
17 +
// pt-BR label for the app-timezone day window, kept beside the window helpers
18 +
// so the wording and the window it names live in one place
19 +
export const DAY_WINDOW_LABEL = "hoje";
20 +
17 21
// Start of the day containing `date`, in the app's timezone
18 22
export function startOfAppDay(date: Date = new Date()): Date {
19 23
return startOfDay(date, { in: APP_TIMEZONE });
24 +
}
25 +
26 +
// Start of the app-timezone day immediately before the one containing `date`
27 +
export function previousAppDay(date: Date = new Date()): Date {
28 +
return startOfDay(subDays(date, 1, { in: APP_TIMEZONE }), {
29 +
in: APP_TIMEZONE,
30 +
});
20 31
}
21 32
22 33
// The app-timezone day containing `date`, as a half-open [start, end) window
23 34
export function appDayWindow(date: Date = new Date()): {
24 35
start: Date;
38 49
} {
39 50
const { start, end } = appDayWindow(date);
40 51
41 52
return { periodStart: start.toISOString(), periodEnd: end.toISOString() };
42 53
}
api/tests/jobs/daily-insights.test.ts
1 1
import { describe, it, expect, afterAll, beforeEach } from 'vitest'
2 2
import { processDailyEnergy } from '@app/lib/queue/processors/insights/dailyEnergy'
3 3
import { processDailySleep } from '@app/lib/queue/processors/insights/dailySleep'
4 4
import { appDayWindow } from '@app/lib/queue/processors/insights/utils'
5 5
import { buildPrisma, cleanupTestDb, createTestUser } from '../test-helper'
67 67
where: { userId: user.id, type: 'DAILY_ENERGY' },
68 68
})
69 69
70 70
expect(insight).toBeNull()
71 71
})
72 +
73 +
it('compares today against yesterday and labels the window', async () => {
74 +
const user = await createTestUser(db)
75 +
const ref = new Date()
76 +
const { start } = appDayWindow(ref)
77 +
const { start: yesterday } = appDayWindow(new Date(start.getTime() - 1))
78 +
79 +
await createMood(user.id, 8, new Date(start.getTime() + HOUR))
80 +
await createMood(user.id, 6, new Date(start.getTime() + 2 * HOUR))
81 +
await createMood(user.id, 3, new Date(yesterday.getTime() + HOUR))
82 +
await createMood(user.id, 5, new Date(yesterday.getTime() + 2 * HOUR))
83 +
84 +
await processDailyEnergy({
85 +
userId: user.id,
86 +
periodStart: ref.toISOString(),
87 +
periodEnd: ref.toISOString(),
88 +
})
89 +
90 +
const insight = await db.insight.findFirst({
91 +
where: { userId: user.id, type: 'DAILY_ENERGY' },
92 +
})
93 +
94 +
expect(insight).not.toBeNull()
95 +
expect(insight!.metadata).toMatchObject({
96 +
avgEnergy: 7,
97 +
yesterdayAvg: 4,
98 +
diffEnergy: 3,
99 +
windowLabel: 'hoje',
100 +
})
101 +
})
102 +
103 +
it('omits the comparison when yesterday has no mood entry', async () => {
104 +
const user = await createTestUser(db)
105 +
const ref = new Date()
106 +
const { start } = appDayWindow(ref)
107 +
108 +
await createMood(user.id, 6, new Date(start.getTime() + HOUR))
109 +
110 +
await processDailyEnergy({
111 +
userId: user.id,
112 +
periodStart: ref.toISOString(),
113 +
periodEnd: ref.toISOString(),
114 +
})
115 +
116 +
const insight = await db.insight.findFirst({
117 +
where: { userId: user.id, type: 'DAILY_ENERGY' },
118 +
})
119 +
120 +
expect(insight).not.toBeNull()
121 +
expect(insight!.metadata).toMatchObject({
122 +
avgEnergy: 6,
123 +
yesterdayAvg: null,
124 +
diffEnergy: null,
125 +
})
126 +
})
72 127
})
73 128
74 129
describe('processDailySleep', () => {
75 130
it('sums today and compares against yesterday', async () => {
76 131
const user = await createTestUser(db)
122 177
const insight = await db.insight.findFirst({
123 178
where: { userId: user.id, type: 'DAILY_SLEEP' },
124 179
})
125 180
126 181
expect(insight).toBeNull()
182 +
})
183 +
184 +
it('labels the window and names the extra nap sessions', async () => {
185 +
const user = await createTestUser(db)
186 +
const ref = new Date()
187 +
const { start: today } = appDayWindow(ref)
188 +
189 +
await db.sleepRecord.create({
190 +
data: { userId: user.id, date: today, average: 5 },
191 +
})
192 +
await db.sleepRecord.create({
193 +
data: { userId: user.id, date: today, average: 2 },
194 +
})
195 +
196 +
await processDailySleep({
197 +
userId: user.id,
198 +
periodStart: ref.toISOString(),
199 +
periodEnd: ref.toISOString(),
200 +
})
201 +
202 +
const insight = await db.insight.findFirst({
203 +
where: { userId: user.id, type: 'DAILY_SLEEP' },
204 +
})
205 +
206 +
expect(insight).not.toBeNull()
207 +
expect(insight!.metadata).toMatchObject({
208 +
totalHours: 7,
209 +
sessionCount: 2,
210 +
napStr: 'incluindo 1 cochilo',
211 +
windowLabel: 'hoje',
212 +
})
213 +
})
214 +
215 +
it('omits the comparison when yesterday has no sleep record', async () => {
216 +
const user = await createTestUser(db)
217 +
const ref = new Date()
218 +
const { start: today } = appDayWindow(ref)
219 +
220 +
await db.sleepRecord.create({
221 +
data: { userId: user.id, date: today, average: 6 },
222 +
})
223 +
224 +
await processDailySleep({
225 +
userId: user.id,
226 +
periodStart: ref.toISOString(),
227 +
periodEnd: ref.toISOString(),
228 +
})
229 +
230 +
const insight = await db.insight.findFirst({
231 +
where: { userId: user.id, type: 'DAILY_SLEEP' },
232 +
})
233 +
234 +
expect(insight).not.toBeNull()
235 +
expect(insight!.metadata).toMatchObject({
236 +
totalHours: 6,
237 +
diffMinutes: null,
238 +
diffStr: '',
239 +
})
127 240
})
128 241
})