eletrotupi / tcc/ commit / 68b32b6

api: lift the daily insight math out of its Prisma wrappers

The energy averages, peak and trough, intra-day trend, and yesterday
delta, plus the sleep totals, minute difference, and nap count, all ran
only after a fetch. Taking the rows the select returns and giving back
the metadata and body makes them callable without a database; the
processors keep their fetch and upsert, so the stored Insight does not
change
Pedro Lucas Porcellis porcellis@eletrotupi.com · 10 days ago 68b32b6167f98c0777e1cc816c4065363f24fba4 Browse files
Parents 7ff50aa
Changed files
4
Additions
+326
Deletions
-78
api/src/lib/queue/processors/insights/dailyEnergy.ts
1 1
import { prisma } from "@app/lib/prisma";
2 2
import { InsightType, InsightPeriod } from "@prisma/client";
3 3
import { InsightPeriodPayload } from "@app/lib/queue/types";
4 4
import { avg } from "@app/utils/calc";
5 5
import {
6 6
  appDayWindow,
7 7
  previousAppDay,
8 8
  DAY_WINDOW_LABEL,
9 9
} from "@app/lib/queue/processors/insights/utils";
10 10

11 -
export async function processDailyEnergy({
12 -
  userId,
13 -
  periodStart,
14 -
}: InsightPeriodPayload): Promise<void> {
15 -
  const { start, end } = appDayWindow(new Date(periodStart));
16 -
  const yesterdayStart = previousAppDay(start);
17 -

18 -
  const [moods, yesterdayMoods] = await Promise.all([
19 -
    prisma.mood.findMany({
20 -
      where: { userId, moment: { gte: start, lt: end } },
21 -
      orderBy: { moment: "asc" },
22 -
      select: {
23 -
        energyLevel: true,
24 -
        stressLevel: true,
25 -
        anxietyLevel: true,
26 -
        selectedMood: true,
27 -
        moment: true,
28 -
      },
29 -
    }),
30 -
    prisma.mood.findMany({
31 -
      where: { userId, moment: { gte: yesterdayStart, lt: start } },
32 -
      select: { energyLevel: true },
33 -
    }),
34 -
  ]);
11 +
type EnergyEntry = {
12 +
  energyLevel: number;
13 +
  stressLevel: number;
14 +
  anxietyLevel: number;
15 +
};
35 16

36 -
  if (moods.length === 0) return;
17 +
export type DailyEnergyMetadata = {
18 +
  avgEnergy: number;
19 +
  avgStress: number;
20 +
  avgAnxiety: number;
21 +
  peak: number;
22 +
  trough: number;
23 +
  dayTrend: number | null;
24 +
  entryCount: number;
25 +
  yesterdayAvg: number | null;
26 +
  diffEnergy: number | null;
27 +
  windowLabel: string;
28 +
};
37 29

30 +
export function computeDailyEnergy(
31 +
  moods: EnergyEntry[],
32 +
  yesterdayMoods: { energyLevel: number }[],
33 +
): { metadata: DailyEnergyMetadata; dayTrend: number | null } {
38 34
  const avgEnergy = parseFloat(avg(moods.map((m) => m.energyLevel)).toFixed(2));
39 35
  const avgStress = parseFloat(avg(moods.map((m) => m.stressLevel)).toFixed(2));
40 36
  const avgAnxiety = parseFloat(
41 37
    avg(moods.map((m) => m.anxietyLevel)).toFixed(2),
42 38
  );
69 65
  const diffEnergy =
70 66
    yesterdayAvg !== null
71 67
      ? parseFloat((avgEnergy - yesterdayAvg).toFixed(2))
72 68
      : null;
73 69

74 -
  const metadata = {
70 +
  const metadata: DailyEnergyMetadata = {
75 71
    avgEnergy,
76 72
    avgStress,
77 73
    avgAnxiety,
78 74
    peak,
79 75
    trough,
82 78
    yesterdayAvg,
83 79
    diffEnergy,
84 80
    windowLabel: DAY_WINDOW_LABEL,
85 81
  };
86 82

83 +
  return { metadata, dayTrend };
84 +
}
85 +

86 +
export function dailyEnergyBody(
87 +
  avgEnergy: number,
88 +
  dayTrend: number | null,
89 +
): string {
90 +
  const level = avgEnergy >= 7 ? "alta" : avgEnergy >= 4 ? "moderada" : "baixa";
91 +
  if (dayTrend === null) return `Energia ${level} hoje.`;
92 +
  if (dayTrend > 10)
93 +
    return `Energia ${level} hoje, com melhora ao longo do dia.`;
94 +
  if (dayTrend < -10)
95 +
    return `Energia ${level} hoje, com queda ao longo do dia.`;
96 +
  return `Energia ${level} e estável ao longo do dia.`;
97 +
}
98 +

99 +
export async function processDailyEnergy({
100 +
  userId,
101 +
  periodStart,
102 +
}: InsightPeriodPayload): Promise<void> {
103 +
  const { start, end } = appDayWindow(new Date(periodStart));
104 +
  const yesterdayStart = previousAppDay(start);
105 +

106 +
  const [moods, yesterdayMoods] = await Promise.all([
107 +
    prisma.mood.findMany({
108 +
      where: { userId, moment: { gte: start, lt: end } },
109 +
      orderBy: { moment: "asc" },
110 +
      select: {
111 +
        energyLevel: true,
112 +
        stressLevel: true,
113 +
        anxietyLevel: true,
114 +
        selectedMood: true,
115 +
        moment: true,
116 +
      },
117 +
    }),
118 +
    prisma.mood.findMany({
119 +
      where: { userId, moment: { gte: yesterdayStart, lt: start } },
120 +
      select: { energyLevel: true },
121 +
    }),
122 +
  ]);
123 +

124 +
  if (moods.length === 0) return;
125 +

126 +
  const { metadata, dayTrend } = computeDailyEnergy(moods, yesterdayMoods);
127 +

87 128
  await prisma.insight.upsert({
88 129
    where: {
89 130
      userId_type_periodStart: {
90 131
        userId,
91 132
        type: InsightType.DAILY_ENERGY,
92 133
        periodStart: start,
93 134
      },
94 135
    },
95 136
    update: {
96 -
      body: buildBody(avgEnergy, dayTrend),
137 +
      body: dailyEnergyBody(metadata.avgEnergy, dayTrend),
97 138
      metadata,
98 139
      generatedAt: new Date(),
99 140
    },
100 141
    create: {
101 142
      userId,
102 143
      type: InsightType.DAILY_ENERGY,
103 144
      period: InsightPeriod.DAILY,
104 145
      title: "Energia do Dia",
105 -
      body: buildBody(avgEnergy, dayTrend),
146 +
      body: dailyEnergyBody(metadata.avgEnergy, dayTrend),
106 147
      metadata,
107 148
      periodStart: start,
108 149
      periodEnd: end,
109 150
    },
110 151
  });
111 152
}
112 -

113 -
function buildBody(avgEnergy: number, dayTrend: number | null): string {
114 -
  const level = avgEnergy >= 7 ? "alta" : avgEnergy >= 4 ? "moderada" : "baixa";
115 -
  if (dayTrend === null) return `Energia ${level} hoje.`;
116 -
  if (dayTrend > 10)
117 -
    return `Energia ${level} hoje, com melhora ao longo do dia.`;
118 -
  if (dayTrend < -10)
119 -
    return `Energia ${level} hoje, com queda ao longo do dia.`;
120 -
  return `Energia ${level} e estável ao longo do dia.`;
121 -
}
api/src/lib/queue/processors/insights/dailySleep.ts
1 1
import { prisma } from "@app/lib/prisma";
2 2
import { InsightType, InsightPeriod } from "@prisma/client";
3 3
import { InsightPeriodPayload } from "@app/lib/queue/types";
4 4
import { logger } from "@app/lib/logger";
5 5
import { formatHours, formatDiff } from "@app/utils/calc";
7 7
  appDayWindow,
8 8
  previousAppDay,
9 9
  DAY_WINDOW_LABEL,
10 10
} from "@app/lib/queue/processors/insights/utils";
11 11

12 -
export async function processDailySleep({
13 -
  userId,
14 -
  periodStart,
15 -
}: InsightPeriodPayload): Promise<void> {
16 -
  const { start: today, end: tomorrow } = appDayWindow(new Date(periodStart));
17 -
  const yesterday = previousAppDay(today);
18 -

19 -
  const [todayRecords, yesterdayRecords] = await Promise.all([
20 -
    prisma.sleepRecord.findMany({
21 -
      where: { userId, date: { gte: today, lt: tomorrow } },
22 -
      select: { average: true },
23 -
    }),
24 -
    prisma.sleepRecord.findMany({
25 -
      where: { userId, date: { gte: yesterday, lt: today } },
26 -
      select: { average: true },
27 -
    }),
28 -
  ]);
29 -

30 -
  logger.debug(
31 -
    { todayRecords, yesterdayRecords },
32 -
    "[insights] daily sleep records",
33 -
  );
12 +
type SleepRecordEntry = { average: number };
34 13

35 -
  if (todayRecords.length === 0) return;
14 +
export type DailySleepMetadata = {
15 +
  totalHours: number;
16 +
  diffMinutes: number | null;
17 +
  diffStr: string;
18 +
  sessionCount: number;
19 +
  napStr: string;
20 +
  hadNaps: boolean;
21 +
  windowLabel: string;
22 +
};
36 23

24 +
export function computeDailySleep(
25 +
  todayRecords: SleepRecordEntry[],
26 +
  yesterdayRecords: SleepRecordEntry[],
27 +
): { metadata: DailySleepMetadata } {
37 28
  // average is stored in hours
38 29
  // sum across all records for the day
39 -
  const totalToday = todayRecords.reduce(
40 -
    (sum: number, r: { average: number }) => sum + r.average,
41 -
    0,
42 -
  );
30 +
  const totalToday = todayRecords.reduce((sum, r) => sum + r.average, 0);
43 31
  const totalYesterday =
44 32
    yesterdayRecords.length > 0
45 -
      ? yesterdayRecords.reduce(
46 -
          (sum: number, r: { average: number }) => sum + r.average,
47 -
          0,
48 -
        )
33 +
      ? yesterdayRecords.reduce((sum, r) => sum + r.average, 0)
49 34
      : null;
50 35

51 36
  const diffMinutes =
52 37
    totalYesterday !== null
53 38
      ? Math.round((totalToday - totalYesterday) * 60)
63 48
  const napStr =
64 49
    sessionCount > 1
65 50
      ? `incluindo ${sessionCount - 1} cochilo${sessionCount > 2 ? "s" : ""}`
66 51
      : "";
67 52

68 -
  const metadata = {
53 +
  const metadata: DailySleepMetadata = {
69 54
    totalHours: totalToday,
70 55
    diffMinutes,
71 56
    diffStr,
72 57
    sessionCount,
73 58
    napStr,
74 59
    hadNaps: sessionCount > 1,
75 60
    windowLabel: DAY_WINDOW_LABEL,
76 61
  };
77 62

63 +
  return { metadata };
64 +
}
65 +

66 +
export function dailySleepBody(total: number): string {
67 +
  const totalStr = formatHours(total);
68 +

69 +
  return `${totalStr} de sono hoje`;
70 +
}
71 +

72 +
export async function processDailySleep({
73 +
  userId,
74 +
  periodStart,
75 +
}: InsightPeriodPayload): Promise<void> {
76 +
  const { start: today, end: tomorrow } = appDayWindow(new Date(periodStart));
77 +
  const yesterday = previousAppDay(today);
78 +

79 +
  const [todayRecords, yesterdayRecords] = await Promise.all([
80 +
    prisma.sleepRecord.findMany({
81 +
      where: { userId, date: { gte: today, lt: tomorrow } },
82 +
      select: { average: true },
83 +
    }),
84 +
    prisma.sleepRecord.findMany({
85 +
      where: { userId, date: { gte: yesterday, lt: today } },
86 +
      select: { average: true },
87 +
    }),
88 +
  ]);
89 +

90 +
  logger.debug(
91 +
    { todayRecords, yesterdayRecords },
92 +
    "[insights] daily sleep records",
93 +
  );
94 +

95 +
  if (todayRecords.length === 0) return;
96 +

97 +
  const { metadata } = computeDailySleep(todayRecords, yesterdayRecords);
98 +

78 99
  await prisma.insight.upsert({
79 100
    where: {
80 101
      userId_type_periodStart: {
81 102
        userId,
82 103
        type: InsightType.DAILY_SLEEP,
83 104
        periodStart: today,
84 105
      },
85 106
    },
86 107
    update: {
87 -
      body: buildBody(totalToday),
108 +
      body: dailySleepBody(metadata.totalHours),
88 109
      metadata,
89 110
      generatedAt: new Date(),
90 111
    },
91 112
    create: {
92 113
      userId,
93 114
      type: InsightType.DAILY_SLEEP,
94 115
      period: InsightPeriod.DAILY,
95 116
      title: "Sono",
96 -
      body: buildBody(totalToday),
117 +
      body: dailySleepBody(metadata.totalHours),
97 118
      metadata,
98 119
      periodStart: today,
99 120
      periodEnd: tomorrow,
100 121
    },
101 122
  });
102 123
}
103 -

104 -
function buildBody(total: number): string {
105 -
  const totalStr = formatHours(total);
106 -

107 -
  return `${totalStr} de sono hoje`;
108 -
}
/dev/null → api/tests/insights/daily-energy.unit.test.ts
1 +
import { describe, it, expect } from 'vitest'
2 +
import {
3 +
  computeDailyEnergy,
4 +
  dailyEnergyBody,
5 +
} from '@app/lib/queue/processors/insights/dailyEnergy'
6 +

7 +
const mood = (energyLevel: number, stressLevel: number, anxietyLevel: number) => ({
8 +
  energyLevel,
9 +
  stressLevel,
10 +
  anxietyLevel,
11 +
})
12 +

13 +
describe('computeDailyEnergy', () => {
14 +
  it('averages the levels, finds the peaks, and trends the day', () => {
15 +
    const { metadata, dayTrend } = computeDailyEnergy(
16 +
      [mood(8, 2, 1), mood(4, 4, 3), mood(6, 6, 5)],
17 +
      [],
18 +
    )
19 +

20 +
    expect(metadata).toEqual({
21 +
      avgEnergy: 6,
22 +
      avgStress: 4,
23 +
      avgAnxiety: 3,
24 +
      peak: 8,
25 +
      trough: 4,
26 +
      dayTrend: -37.5,
27 +
      entryCount: 3,
28 +
      yesterdayAvg: null,
29 +
      diffEnergy: null,
30 +
      windowLabel: 'hoje',
31 +
    })
32 +
    expect(dayTrend).toBe(-37.5)
33 +
  })
34 +

35 +
  it('rounds the averages to two decimals', () => {
36 +
    const { metadata } = computeDailyEnergy(
37 +
      [mood(7, 7, 7), mood(8, 8, 8), mood(8, 8, 8)],
38 +
      [],
39 +
    )
40 +

41 +
    expect(metadata).toMatchObject({
42 +
      avgEnergy: 7.67,
43 +
      avgStress: 7.67,
44 +
      avgAnxiety: 7.67,
45 +
      peak: 8,
46 +
      trough: 7,
47 +
    })
48 +
  })
49 +

50 +
  it('has no day trend with a single entry', () => {
51 +
    const { metadata, dayTrend } = computeDailyEnergy([mood(5, 1, 1)], [])
52 +

53 +
    expect(dayTrend).toBeNull()
54 +
    expect(metadata).toMatchObject({
55 +
      dayTrend: null,
56 +
      entryCount: 1,
57 +
      peak: 5,
58 +
      trough: 5,
59 +
    })
60 +
  })
61 +

62 +
  it('subtracts yesterday from the rounded today average, either way', () => {
63 +
    const up = computeDailyEnergy(
64 +
      [mood(6, 1, 1), mood(6, 1, 1)],
65 +
      [{ energyLevel: 4 }, { energyLevel: 4 }],
66 +
    ).metadata
67 +

68 +
    const down = computeDailyEnergy(
69 +
      [mood(4, 1, 1), mood(4, 1, 1)],
70 +
      [{ energyLevel: 9 }, { energyLevel: 9 }],
71 +
    ).metadata
72 +

73 +
    expect(up).toMatchObject({
74 +
      avgEnergy: 6,
75 +
      yesterdayAvg: 4,
76 +
      diffEnergy: 2,
77 +
      dayTrend: 0,
78 +
    })
79 +
    expect(down).toMatchObject({
80 +
      avgEnergy: 4,
81 +
      yesterdayAvg: 9,
82 +
      diffEnergy: -5,
83 +
    })
84 +
  })
85 +
})
86 +

87 +
describe('dailyEnergyBody', () => {
88 +
  it('picks the level from the average energy', () => {
89 +
    expect(dailyEnergyBody(7, null)).toBe('Energia alta hoje.')
90 +
    expect(dailyEnergyBody(4, null)).toBe('Energia moderada hoje.')
91 +
    expect(dailyEnergyBody(3, null)).toBe('Energia baixa hoje.')
92 +
  })
93 +

94 +
  it('adds the day trend when there is one', () => {
95 +
    expect(dailyEnergyBody(5, 20)).toBe(
96 +
      'Energia moderada hoje, com melhora ao longo do dia.',
97 +
    )
98 +
    expect(dailyEnergyBody(5, -20)).toBe(
99 +
      'Energia moderada hoje, com queda ao longo do dia.',
100 +
    )
101 +
    expect(dailyEnergyBody(5, 0)).toBe(
102 +
      'Energia moderada e estável ao longo do dia.',
103 +
    )
104 +
  })
105 +

106 +
  it('treats a trend of exactly ten as stable', () => {
107 +
    expect(dailyEnergyBody(5, 10)).toBe(
108 +
      'Energia moderada e estável ao longo do dia.',
109 +
    )
110 +
    expect(dailyEnergyBody(5, -10)).toBe(
111 +
      'Energia moderada e estável ao longo do dia.',
112 +
    )
113 +
    expect(dailyEnergyBody(5, 11)).toBe(
114 +
      'Energia moderada hoje, com melhora ao longo do dia.',
115 +
    )
116 +
    expect(dailyEnergyBody(5, -11)).toBe(
117 +
      'Energia moderada hoje, com queda ao longo do dia.',
118 +
    )
119 +
  })
120 +
})
/dev/null → api/tests/insights/daily-sleep.unit.test.ts
1 +
import { describe, it, expect } from 'vitest'
2 +
import {
3 +
  computeDailySleep,
4 +
  dailySleepBody,
5 +
} from '@app/lib/queue/processors/insights/dailySleep'
6 +

7 +
const record = (average: number) => ({ average })
8 +

9 +
describe('computeDailySleep', () => {
10 +
  it('sums today and compares it against yesterday', () => {
11 +
    const { metadata } = computeDailySleep([record(8)], [record(6)])
12 +

13 +
    expect(metadata).toEqual({
14 +
      totalHours: 8,
15 +
      diffMinutes: 120,
16 +
      diffStr: '+2h que ontem',
17 +
      sessionCount: 1,
18 +
      napStr: '',
19 +
      hadNaps: false,
20 +
      windowLabel: 'hoje',
21 +
    })
22 +
  })
23 +

24 +
  it('omits the difference when nothing was logged yesterday', () => {
25 +
    const { metadata } = computeDailySleep([record(6)], [])
26 +

27 +
    expect(metadata).toMatchObject({
28 +
      totalHours: 6,
29 +
      diffMinutes: null,
30 +
      diffStr: '',
31 +
    })
32 +
  })
33 +

34 +
  it('subtracts yesterday from today when yesterday was longer', () => {
35 +
    const { metadata } = computeDailySleep([record(4)], [record(9)])
36 +

37 +
    expect(metadata).toMatchObject({
38 +
      totalHours: 4,
39 +
      diffMinutes: -300,
40 +
      diffStr: '-5h que ontem',
41 +
    })
42 +
  })
43 +

44 +
  it('names the extra sessions as naps, pluralized past two', () => {
45 +
    const oneNap = computeDailySleep([record(5), record(2)], []).metadata
46 +
    const twoNaps = computeDailySleep(
47 +
      [record(5), record(2), record(1)],
48 +
      [],
49 +
    ).metadata
50 +

51 +
    expect(oneNap).toMatchObject({
52 +
      totalHours: 7,
53 +
      sessionCount: 2,
54 +
      napStr: 'incluindo 1 cochilo',
55 +
      hadNaps: true,
56 +
    })
57 +
    expect(twoNaps).toMatchObject({
58 +
      totalHours: 8,
59 +
      sessionCount: 3,
60 +
      napStr: 'incluindo 2 cochilos',
61 +
    })
62 +
  })
63 +

64 +
  it('drops the difference string under five minutes', () => {
65 +
    const under = computeDailySleep([record(6)], [record(6.05)]).metadata
66 +
    const at = computeDailySleep([record(6)], [record(5.9)]).metadata
67 +

68 +
    expect(under).toMatchObject({ diffMinutes: -3, diffStr: '' })
69 +
    expect(at).toMatchObject({ diffMinutes: 6, diffStr: '+6min que ontem' })
70 +
  })
71 +
})
72 +

73 +
describe('dailySleepBody', () => {
74 +
  it('formats a whole hour and a remainder', () => {
75 +
    expect(dailySleepBody(8)).toBe('8h de sono hoje')
76 +
    expect(dailySleepBody(7.5)).toBe('7h 30min de sono hoje')
77 +
  })
78 +

79 +
  it('renders a day with nothing logged as zero hours', () => {
80 +
    expect(dailySleepBody(0)).toBe('0h de sono hoje')
81 +
  })
82 +
})