api: aggregate and generate a daily sleep insight
Parents:
fc92b327 file(s) changed
- api/prisma/migrations/20260614075859_add_daily_sleep_insight_type/migration.sql +2 -0
- api/prisma/schema.prisma +1 -0
- api/src/lib/queue/processors/insights/dailySleep.ts +106 -0
- api/src/lib/queue/processors/insights/index.ts +4 -0
- api/src/lib/queue/types.ts +4 -2
- api/src/services/sleepRecord.service.ts +28 -9
- api/src/utils/calc.ts +21 -0
api/prisma/migrations/20260614075859_add_daily_sleep_insight_type/migration.sql
@@ -0,0 +1,2 @@
1 + -- AlterEnum
2 + ALTER TYPE "InsightType" ADD VALUE 'DAILY_SLEEP';
api/prisma/schema.prisma
1 1
generator client {
2 2
provider = "prisma-client-js"
3 3
//output = "../src/generated/prisma"
4 4
}
5 5
▸ 131 unchanged lines
137 137
ENERGY_SLEEP_CORRELATION
138 138
TRIGGER_PATTERN
139 139
WEEKLY_SUMMARY
140 140
STREAK
141 141
DAILY_ENERGY
142 +
DAILY_SLEEP
142 143
}
143 144
144 145
enum InsightPeriod {
145 146
DAILY
146 147
WEEKLY
▸ 40 unchanged lines
187 188
ANXIETY
188 189
GRATITUDE
189 190
FOCUS
190 191
TIREDNESS
191 192
}
api/src/lib/queue/processors/insights/dailySleep.ts
@@ -0,0 +1,106 @@
1 + import { prisma } from "@app/lib/prisma";
2 + import { InsightType, InsightPeriod } from "@prisma/client";
3 + import { InsightPeriodPayload } from "@app/lib/queue/types";
4 + // XXX: date-fns?
5 + import { formatHours, formatDiff } from "@app/utils/calc";
6 + import { startOfDay, addDays, subDays } from "date-fns";
7 +
8 + export async function processDailySleep({
9 + userId,
10 + periodStart,
11 + }: InsightPeriodPayload): Promise<void> {
12 + const today = startOfDay(new Date(periodStart));
13 + const tomorrow = addDays(today, 1);
14 + const yesterday = subDays(today, 1);
15 +
16 + const [todayRecords, yesterdayRecords] = await Promise.all([
17 + prisma.sleepRecord.findMany({
18 + where: { userId, date: { gte: today, lt: tomorrow } },
19 + select: { average: true },
20 + }),
21 + prisma.sleepRecord.findMany({
22 + where: { userId, date: { gte: yesterday, lt: today } },
23 + select: { average: true },
24 + }),
25 + ]);
26 +
27 + console.log("todayRecords, yesterdayRecords", todayRecords, yesterdayRecords);
28 +
29 + if (todayRecords.length === 0) return;
30 +
31 + // average is stored in hours
32 + // sum across all records for the day
33 + const totalToday = todayRecords.reduce(
34 + (sum: number, r: { average: number }) => sum + r.average,
35 + 0,
36 + );
37 + const totalYesterday =
38 + yesterdayRecords.length > 0
39 + ? yesterdayRecords.reduce(
40 + (sum: number, r: { average: number }) => sum + r.average,
41 + 0,
42 + )
43 + : null;
44 +
45 + const diffMinutes =
46 + totalYesterday !== null
47 + ? Math.round((totalToday - totalYesterday) * 60)
48 + : null;
49 +
50 + const sessionCount = todayRecords.length; // 1 = normal night, 2+ = nap(s)
51 +
52 + const diffStr =
53 + diffMinutes !== null && Math.abs(diffMinutes) >= 5
54 + ? `${formatDiff(diffMinutes)} que ontem`
55 + : "";
56 +
57 + const napStr =
58 + sessionCount > 1
59 + ? `incluindo ${sessionCount - 1} cochilo${sessionCount > 2 ? "s" : ""}`
60 + : "";
61 +
62 + await prisma.insight.upsert({
63 + where: {
64 + userId_type_periodStart: {
65 + userId,
66 + type: InsightType.DAILY_SLEEP,
67 + periodStart: today,
68 + },
69 + },
70 + update: {
71 + body: buildBody(totalToday),
72 + metadata: {
73 + totalHours: totalToday,
74 + diffMinutes,
75 + diffStr,
76 + sessionCount,
77 + napStr,
78 + hadNaps: sessionCount > 1,
79 + },
80 + generatedAt: new Date(),
81 + },
82 + create: {
83 + userId,
84 + type: InsightType.DAILY_SLEEP,
85 + period: InsightPeriod.DAILY,
86 + title: "Sono",
87 + body: buildBody(totalToday),
88 + metadata: {
89 + totalHours: totalToday,
90 + diffMinutes,
91 + sessionCount,
92 + diffStr,
93 + napStr,
94 + hadNaps: sessionCount > 1,
95 + },
96 + periodStart: today,
97 + periodEnd: tomorrow,
98 + },
99 + });
100 + }
101 +
102 + function buildBody(total: number): string {
103 + const totalStr = formatHours(total);
104 +
105 + return `${totalStr} de sono hoje`;
106 + }
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,
▸ 2 unchanged lines
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 12
import { processDailyEnergy } from "@app/lib/queue/processors/insights/dailyEnergy";
13 +
import { processDailySleep } from "@app/lib/queue/processors/insights/dailySleep";
13 14
14 15
export async function insightProcessor(
15 16
job: Job<InsightJobData["data"]>,
16 17
): Promise<void> {
17 18
console.log(`Insight job ${job.name} started`);
▸ 11 unchanged lines
29 30
case InsightJobName.TriggerPattern:
30 31
return processTriggerPattern(job.data as InsightPeriodPayload);
31 32
32 33
case InsightJobName.DailyEnergy:
33 34
return processDailyEnergy(job.data as InsightPeriodPayload);
35 +
36 +
case InsightJobName.DailySleep:
37 +
return processDailySleep(job.data as InsightPeriodPayload);
34 38
35 39
default:
36 40
throw new Error(`Unknown insight job: ${job.name}`);
37 41
}
38 42
}
▸ 23 unchanged lines
62 66
data: { userId: u.id, ...payload },
63 67
},
64 68
]),
65 69
);
66 70
}
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 +
DailyEnergy = "insight:daily-energy",
27 +
DailySleep = "insight:daily-sleep",
27 28
}
28 29
29 30
export type InsightPeriodPayload = {
30 31
userId: number;
31 32
periodStart: string;
▸ 5 unchanged lines
37 38
export type InsightJobData =
38 39
| { name: InsightJobName.FanOut; data: InsightFanOutPayload }
39 40
| { name: InsightJobName.MoodTrend; data: InsightPeriodPayload }
40 41
| { name: InsightJobName.EnergySleep; data: InsightPeriodPayload }
41 42
| { name: InsightJobName.TriggerPattern; data: InsightPeriodPayload }
42 -
| { name: InsightJobName.DailyEnergy; data: InsightPeriodPayload };
43 +
| { name: InsightJobName.DailyEnergy; data: InsightPeriodPayload }
44 +
| { name: InsightJobName.DailySleep; data: InsightPeriodPayload };
api/src/services/sleepRecord.service.ts
1 1
import { prisma } from '@app/lib/prisma';
2 2
import { SleepRecord } from '@prisma/client';
3 3
4 4
import { CreateSleepRecordInput } from '@app/schemas';
5 5
import { rollingPeriod } from "@app/lib/queue/processors/insights/utils";
6 6
import { InsightJobName } from "@app/lib/queue/types";
7 7
import { getQueue } from "@app/lib/queue";
8 +
import { startOfDay } from "date-fns";
8 9
9 10
export const createSleepRecord = async (
10 11
userId: number, input: CreateSleepRecordInput
11 12
): Promise<SleepRecord> => {
12 13
const sleepRecord = await prisma.sleepRecord.create({
▸ 1 unchanged lines
14 15
...input,
15 16
userId
16 17
}
17 18
});
18 19
19 -
void enqueueEnergySleepInsight(userId);
20 +
void enqueueEnergySleepInsights(userId);
20 21
21 22
return sleepRecord;
22 23
};
23 24
24 25
export const getSleepRecordsByUserId = async (
▸ 48 unchanged lines
73 74
userId
74 75
}
75 76
})
76 77
}
77 78
78 -
async function enqueueEnergySleepInsight(userId: number): Promise<void> {
79 -
await getQueue('insights').add(
80 -
InsightJobName.EnergySleep,
81 -
{ userId, ...rollingPeriod(7) },
79 +
async function enqueueEnergySleepInsights(userId: number): Promise<void> {
80 +
const queue = getQueue("insights");
81 +
const period = rollingPeriod(7);
82 +
const payload = { userId, ...period };
83 +
84 +
await getQueue('insights').addBulk([
82 85
{
83 -
jobId: `energy-sleep-${userId}-${new Date().toDateString()}`,
84 -
removeOnComplete: true,
85 -
}
86 -
);
86 +
name: InsightJobName.EnergySleep,
87 +
data: payload,
88 +
opts: {
89 +
jobId: `energy-sleep-${userId}-${new Date().toDateString()}`,
90 +
removeOnComplete: true,
91 +
}
92 +
},
93 +
{
94 +
name: InsightJobName.DailySleep,
95 +
data: {
96 +
userId,
97 +
periodStart: startOfDay(new Date()).toISOString(),
98 +
periodEnd: new Date()
99 +
},
100 +
opts: {
101 +
jobId: `daily-sleep-${userId}-${new Date().toDateString()}`,
102 +
removeOnComplete: true,
103 +
},
104 +
},
105 +
]);
87 106
}
api/src/utils/calc.ts
1 1
// TODO: Move other calculations here
2 2
export const avg = (values: number[]) =>
3 3
values.reduce((a, b) => a + b, 0) / values.length;
4 +
5 +
export const formatHours = (hours: number): string => {
6 +
const h = Math.floor(hours);
7 +
const m = Math.round((hours - h) * 60);
8 +
9 +
if (m === 0) return `${h}h`;
10 +
11 +
return `${h}h ${m}min`;
12 +
}
13 +
14 +
export const formatDiff = (minutes: number): string => {
15 +
const abs = Math.abs(minutes);
16 +
const h = Math.floor(abs / 60);
17 +
const m = abs % 60;
18 +
const sign = minutes > 0 ? "+" : "-";
19 +
20 +
if (h === 0) return `${sign}${m}min`;
21 +
if (m === 0) return `${sign}${h}h`;
22 +
23 +
return `${sign}${h}h ${m}min`;
24 +
}