api: introduce daily energy insight
Parents:
a2d0b316 file(s) changed
- api/prisma/migrations/20260614055540_add_daily_energy_insight_type/migration.sql +2 -0
- api/prisma/schema.prisma +1 -0
- api/src/lib/queue/processors/insights/dailyEnergy.ts +102 -0
- api/src/lib/queue/processors/insights/index.ts +4 -0
- api/src/lib/queue/types.ts +3 -1
- api/src/utils/calc.ts +3 -0
api/prisma/migrations/20260614055540_add_daily_energy_insight_type/migration.sql
@@ -0,0 +1,2 @@
1 + -- AlterEnum
2 + ALTER TYPE "InsightType" ADD VALUE 'DAILY_ENERGY';
api/prisma/schema.prisma
1 1
generator client {
2 2
provider = "prisma-client-js"
3 3
//output = "../src/generated/prisma"
4 4
}
5 5
▸ 130 unchanged lines
136 136
MOOD_TREND
137 137
ENERGY_SLEEP_CORRELATION
138 138
TRIGGER_PATTERN
139 139
WEEKLY_SUMMARY
140 140
STREAK
141 +
DAILY_ENERGY
141 142
}
142 143
143 144
enum InsightPeriod {
144 145
DAILY
145 146
WEEKLY
▸ 40 unchanged lines
186 187
ANXIETY
187 188
GRATITUDE
188 189
FOCUS
189 190
TIREDNESS
190 191
}
api/src/lib/queue/processors/insights/dailyEnergy.ts
@@ -0,0 +1,102 @@
1 + import { prisma } from "@app/lib/prisma";
2 + import { InsightType, InsightPeriod } from "@prisma/client";
3 + import { InsightPeriodPayload } from "@app/lib/queue/types";
4 + import { avg } from "@app/utils/calc";
5 +
6 + export async function processDailyEnergy({
7 + userId,
8 + periodStart,
9 + periodEnd,
10 + }: InsightPeriodPayload): Promise<void> {
11 + const start = new Date(periodStart);
12 + const end = new Date(periodEnd);
13 +
14 + const moods = await prisma.mood.findMany({
15 + where: { userId, moment: { gte: start, lte: end } },
16 + orderBy: { moment: "asc" },
17 + select: {
18 + energyLevel: true,
19 + stressLevel: true,
20 + anxietyLevel: true,
21 + selectedMood: true,
22 + moment: true,
23 + },
24 + });
25 +
26 + if (moods.length === 0) return;
27 +
28 + const avgEnergy = parseFloat(avg(moods.map((m) => m.energyLevel)).toFixed(2));
29 + const avgStress = parseFloat(avg(moods.map((m) => m.stressLevel)).toFixed(2));
30 + const avgAnxiety = parseFloat(
31 + avg(moods.map((m) => m.anxietyLevel)).toFixed(2),
32 + );
33 + const peak = Math.max(...moods.map((m) => m.energyLevel));
34 + const trough = Math.min(...moods.map((m) => m.energyLevel));
35 +
36 + // Trend across the day: compare first half vs second half entries
37 + // Not that great, but it works
38 + const mid = Math.floor(moods.length / 2);
39 + const morningAvg =
40 + moods.length > 1
41 + ? avg(moods.slice(0, mid).map((m) => m.energyLevel))
42 + : null;
43 +
44 + const eveningAvg =
45 + moods.length > 1 ? avg(moods.slice(mid).map((m) => m.energyLevel)) : null;
46 +
47 + const dayTrend =
48 + morningAvg && eveningAvg
49 + ? parseFloat((((eveningAvg - morningAvg) / morningAvg) * 100).toFixed(1))
50 + : null;
51 +
52 + await prisma.insight.upsert({
53 + where: {
54 + userId_type_periodStart: {
55 + userId,
56 + type: InsightType.DAILY_ENERGY,
57 + periodStart: start,
58 + },
59 + },
60 + update: {
61 + body: buildBody(avgEnergy, dayTrend),
62 + metadata: {
63 + avgEnergy,
64 + avgStress,
65 + avgAnxiety,
66 + peak,
67 + trough,
68 + dayTrend,
69 + entryCount: moods.length,
70 + },
71 + generatedAt: new Date(),
72 + },
73 + create: {
74 + userId,
75 + type: InsightType.DAILY_ENERGY,
76 + period: InsightPeriod.DAILY,
77 + title: "Energia do Dia",
78 + body: buildBody(avgEnergy, dayTrend),
79 + metadata: {
80 + avgEnergy,
81 + avgStress,
82 + avgAnxiety,
83 + peak,
84 + trough,
85 + dayTrend,
86 + entryCount: moods.length,
87 + },
88 + periodStart: start,
89 + periodEnd: end,
90 + },
91 + });
92 + }
93 +
94 + function buildBody(avgEnergy: number, dayTrend: number | null): string {
95 + const level = avgEnergy >= 7 ? "alta" : avgEnergy >= 4 ? "moderada" : "baixa";
96 + if (dayTrend === null) return `Energia ${level} hoje.`;
97 + if (dayTrend > 10)
98 + return `Energia ${level} hoje, com melhora ao longo do dia.`;
99 + if (dayTrend < -10)
100 + return `Energia ${level} hoje, com queda ao longo do dia.`;
101 + return `Energia ${level} e estável ao longo do dia.`;
102 + }
api/src/lib/queue/processors/insights/index.ts
1 1
import { Job } from "bullmq";
2 2
import {
3 3
InsightJobName,
4 4
InsightJobData,
5 5
InsightPeriodPayload,
▸ 1 unchanged lines
7 7
import { getQueue } from "@app/lib/queue/QueueRegistry";
8 8
import { prisma } from "@app/lib/prisma";
9 9
import { processMoodTrend } from "@app/lib/queue/processors/insights/moodTrend";
10 10
import { processEnergySleep } from "@app/lib/queue/processors/insights/energySleep";
11 11
import { processTriggerPattern } from "@app/lib/queue/processors/insights/triggerPattern";
12 +
import { processDailyEnergy } from "@app/lib/queue/processors/insights/dailyEnergy";
12 13
13 14
export async function insightProcessor(
14 15
job: Job<InsightJobData["data"]>,
15 16
): Promise<void> {
16 17
console.log(`Insight job ${job.name} started`);
▸ 8 unchanged lines
25 26
case InsightJobName.EnergySleep:
26 27
return processEnergySleep(job.data as InsightPeriodPayload);
27 28
28 29
case InsightJobName.TriggerPattern:
29 30
return processTriggerPattern(job.data as InsightPeriodPayload);
31 +
32 +
case InsightJobName.DailyEnergy:
33 +
return processDailyEnergy(job.data as InsightPeriodPayload);
30 34
31 35
default:
32 36
throw new Error(`Unknown insight job: ${job.name}`);
33 37
}
34 38
}
▸ 23 unchanged lines
58 62
data: { userId: u.id, ...payload },
59 63
},
60 64
]),
61 65
);
62 66
}
api/src/lib/queue/types.ts
1 1
// Mail queue
2 2
export enum MailJobName {
3 3
WelcomeEmail = "mail:welcome",
4 4
PasswordReset = "mail:password-reset",
5 5
ActivateAccountEmail = "mail:activate-account",
▸ 15 unchanged lines
21 21
export enum InsightJobName {
22 22
FanOut = "insight:fan-out",
23 23
MoodTrend = "insight:mood-trend",
24 24
EnergySleep = "insight:energy-sleep",
25 25
TriggerPattern = "insight:trigger-pattern",
26 +
DailyEnergy = "insight:daily-energy"
26 27
}
27 28
28 29
export type InsightPeriodPayload = {
29 30
userId: number;
30 31
periodStart: string;
▸ 4 unchanged lines
35 36
36 37
export type InsightJobData =
37 38
| { name: InsightJobName.FanOut; data: InsightFanOutPayload }
38 39
| { name: InsightJobName.MoodTrend; data: InsightPeriodPayload }
39 40
| { name: InsightJobName.EnergySleep; data: InsightPeriodPayload }
40 -
| { name: InsightJobName.TriggerPattern; data: InsightPeriodPayload };
41 +
| { name: InsightJobName.TriggerPattern; data: InsightPeriodPayload }
42 +
| { name: InsightJobName.DailyEnergy; data: InsightPeriodPayload };
api/src/utils/calc.ts
@@ -0,0 +1,3 @@
1 + // TODO: Move other calculations here
2 + export const avg = (values: number[]) =>
3 + values.reduce((a, b) => a + b, 0) / values.length;