eletrotupi / tcc/ commit / e5be001

api: aggregate and generate a daily sleep insight

Pedro Lucas Porcellis porcellis@eletrotupi.com · 4 months ago e5be001919a9cae27c3ff7ea4dfa6845fdc5f004 Browse files
Parents fc92b32
Changed 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 +
}