eletrotupi / tcc/ commit / e5be001

api: aggregate and generate a daily sleep insight

Pedro Lucas Porcellis porcellis@eletrotupi.com 1 month ago e5be001919a9cae27c3ff7ea4dfa6845fdc5f004
Parents: fc92b32
7 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 +
}