api: aggregate and generate a daily sleep insight
Pedro Lucas Porcellis porcellis@eletrotupi.com · 4 months ago
e5be001919a9cae27c3ff7ea4dfa6845fdc5f004 Browse filesParents
fc92b32Changed files
7
Additions
+166
Deletions
-11
/dev/null → api/prisma/migrations/20260614075859_add_daily_sleep_insight_type/migration.sql
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
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
187 188
ANXIETY
188 189
GRATITUDE
189 190
FOCUS
190 191
TIREDNESS
191 192
}
/dev/null → api/src/lib/queue/processors/insights/dailySleep.ts
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,
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`);
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
}
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",
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;
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({
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 (
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 +
}