eletrotupi / tcc/ commit / de0605f

api/insights: daily insights carry a comparison and window label

DAILY_ENERGY now exposes yesterday's average and a signed delta against
today, null when there is no yesterday data, so the energy card can say
how today compares without inventing a zero. Both daily processors also
author a pt-BR window label, which lets the cards render the period
instead of leaning on the stored sentence
Pedro Lucas Porcellis porcellis@eletrotupi.com · 12 days ago de0605fcb0d1980cedfafe4830a7aeaef31de5dc Browse files
Parents 269c105
Changed files
4
Additions
+193
Deletions
-49
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 -
import { appDayWindow } from "@app/lib/queue/processors/insights/utils";
5 +
import {
6 +
  appDayWindow,
7 +
  previousAppDay,
8 +
  DAY_WINDOW_LABEL,
9 +
} from "@app/lib/queue/processors/insights/utils";
6 10

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

13 -
  const moods = await prisma.mood.findMany({
14 -
    where: { userId, moment: { gte: start, lt: end } },
15 -
    orderBy: { moment: "asc" },
16 -
    select: {
17 -
      energyLevel: true,
18 -
      stressLevel: true,
19 -
      anxietyLevel: true,
20 -
      selectedMood: true,
21 -
      moment: true,
22 -
    },
23 -
  });
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 +
  ]);
24 35

25 36
  if (moods.length === 0) return;
26 37

27 38
  const avgEnergy = parseFloat(avg(moods.map((m) => m.energyLevel)).toFixed(2));
28 39
  const avgStress = parseFloat(avg(moods.map((m) => m.stressLevel)).toFixed(2));
46 57
  const dayTrend =
47 58
    morningAvg && eveningAvg
48 59
      ? parseFloat((((eveningAvg - morningAvg) / morningAvg) * 100).toFixed(1))
49 60
      : null;
50 61

62 +
  const yesterdayAvg =
63 +
    yesterdayMoods.length > 0
64 +
      ? parseFloat(avg(yesterdayMoods.map((m) => m.energyLevel)).toFixed(2))
65 +
      : null;
66 +

67 +
  // Positive means more energy than yesterday; null when there is nothing to
68 +
  // compare against
69 +
  const diffEnergy =
70 +
    yesterdayAvg !== null
71 +
      ? parseFloat((avgEnergy - yesterdayAvg).toFixed(2))
72 +
      : null;
73 +

74 +
  const metadata = {
75 +
    avgEnergy,
76 +
    avgStress,
77 +
    avgAnxiety,
78 +
    peak,
79 +
    trough,
80 +
    dayTrend,
81 +
    entryCount: moods.length,
82 +
    yesterdayAvg,
83 +
    diffEnergy,
84 +
    windowLabel: DAY_WINDOW_LABEL,
85 +
  };
86 +

51 87
  await prisma.insight.upsert({
52 88
    where: {
53 89
      userId_type_periodStart: {
54 90
        userId,
55 91
        type: InsightType.DAILY_ENERGY,
56 92
        periodStart: start,
57 93
      },
58 94
    },
59 95
    update: {
60 96
      body: buildBody(avgEnergy, dayTrend),
61 -
      metadata: {
62 -
        avgEnergy,
63 -
        avgStress,
64 -
        avgAnxiety,
65 -
        peak,
66 -
        trough,
67 -
        dayTrend,
68 -
        entryCount: moods.length,
69 -
      },
97 +
      metadata,
70 98
      generatedAt: new Date(),
71 99
    },
72 100
    create: {
73 101
      userId,
74 102
      type: InsightType.DAILY_ENERGY,
75 103
      period: InsightPeriod.DAILY,
76 104
      title: "Energia do Dia",
77 105
      body: buildBody(avgEnergy, dayTrend),
78 -
      metadata: {
79 -
        avgEnergy,
80 -
        avgStress,
81 -
        avgAnxiety,
82 -
        peak,
83 -
        trough,
84 -
        dayTrend,
85 -
        entryCount: moods.length,
86 -
      },
106 +
      metadata,
87 107
      periodStart: start,
88 108
      periodEnd: end,
89 109
    },
90 110
  });
91 111
}
97 117
    return `Energia ${level} hoje, com melhora ao longo do dia.`;
98 118
  if (dayTrend < -10)
99 119
    return `Energia ${level} hoje, com queda ao longo do dia.`;
100 120
  return `Energia ${level} e estável ao longo do dia.`;
101 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";
6 -
import { appDayWindow } from "@app/lib/queue/processors/insights/utils";
6 +
import {
7 +
  appDayWindow,
8 +
  previousAppDay,
9 +
  DAY_WINDOW_LABEL,
10 +
} from "@app/lib/queue/processors/insights/utils";
7 11

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

15 19
  const [todayRecords, yesterdayRecords] = await Promise.all([
16 20
    prisma.sleepRecord.findMany({
17 21
      where: { userId, date: { gte: today, lt: tomorrow } },
18 22
      select: { average: true },
59 63
  const napStr =
60 64
    sessionCount > 1
61 65
      ? `incluindo ${sessionCount - 1} cochilo${sessionCount > 2 ? "s" : ""}`
62 66
      : "";
63 67

68 +
  const metadata = {
69 +
    totalHours: totalToday,
70 +
    diffMinutes,
71 +
    diffStr,
72 +
    sessionCount,
73 +
    napStr,
74 +
    hadNaps: sessionCount > 1,
75 +
    windowLabel: DAY_WINDOW_LABEL,
76 +
  };
77 +

64 78
  await prisma.insight.upsert({
65 79
    where: {
66 80
      userId_type_periodStart: {
67 81
        userId,
68 82
        type: InsightType.DAILY_SLEEP,
69 83
        periodStart: today,
70 84
      },
71 85
    },
72 86
    update: {
73 87
      body: buildBody(totalToday),
74 -
      metadata: {
75 -
        totalHours: totalToday,
76 -
        diffMinutes,
77 -
        diffStr,
78 -
        sessionCount,
79 -
        napStr,
80 -
        hadNaps: sessionCount > 1,
81 -
      },
88 +
      metadata,
82 89
      generatedAt: new Date(),
83 90
    },
84 91
    create: {
85 92
      userId,
86 93
      type: InsightType.DAILY_SLEEP,
87 94
      period: InsightPeriod.DAILY,
88 95
      title: "Sono",
89 96
      body: buildBody(totalToday),
90 -
      metadata: {
91 -
        totalHours: totalToday,
92 -
        diffMinutes,
93 -
        sessionCount,
94 -
        diffStr,
95 -
        napStr,
96 -
        hadNaps: sessionCount > 1,
97 -
      },
97 +
      metadata,
98 98
      periodStart: today,
99 99
      periodEnd: tomorrow,
100 100
    },
101 101
  });
102 102
}
104 104
function buildBody(total: number): string {
105 105
  const totalStr = formatHours(total);
106 106

107 107
  return `${totalStr} de sono hoje`;
108 108
}
api/src/lib/queue/processors/insights/utils.ts
1 1
import { tz } from "@date-fns/tz";
2 -
import { addDays, startOfDay } from "date-fns";
2 +
import { addDays, startOfDay, subDays } from "date-fns";
3 3

4 4
export function rollingPeriod(days = 7) {
5 5
  const periodEnd = new Date();
6 6
  const periodStart = new Date();
7 7
  periodStart.setDate(periodEnd.getDate() - days);
12 12
  };
13 13
}
14 14

15 15
const APP_TIMEZONE = tz("America/Sao_Paulo");
16 16

17 +
// pt-BR label for the app-timezone day window, kept beside the window helpers
18 +
// so the wording and the window it names live in one place
19 +
export const DAY_WINDOW_LABEL = "hoje";
20 +

17 21
// Start of the day containing `date`, in the app's timezone
18 22
export function startOfAppDay(date: Date = new Date()): Date {
19 23
  return startOfDay(date, { in: APP_TIMEZONE });
24 +
}
25 +

26 +
// Start of the app-timezone day immediately before the one containing `date`
27 +
export function previousAppDay(date: Date = new Date()): Date {
28 +
  return startOfDay(subDays(date, 1, { in: APP_TIMEZONE }), {
29 +
    in: APP_TIMEZONE,
30 +
  });
20 31
}
21 32

22 33
// The app-timezone day containing `date`, as a half-open [start, end) window
23 34
export function appDayWindow(date: Date = new Date()): {
24 35
  start: Date;
38 49
} {
39 50
  const { start, end } = appDayWindow(date);
40 51

41 52
  return { periodStart: start.toISOString(), periodEnd: end.toISOString() };
42 53
}
api/tests/jobs/daily-insights.test.ts
1 1
import { describe, it, expect, afterAll, beforeEach } from 'vitest'
2 2
import { processDailyEnergy } from '@app/lib/queue/processors/insights/dailyEnergy'
3 3
import { processDailySleep } from '@app/lib/queue/processors/insights/dailySleep'
4 4
import { appDayWindow } from '@app/lib/queue/processors/insights/utils'
5 5
import { buildPrisma, cleanupTestDb, createTestUser } from '../test-helper'
67 67
      where: { userId: user.id, type: 'DAILY_ENERGY' },
68 68
    })
69 69

70 70
    expect(insight).toBeNull()
71 71
  })
72 +

73 +
  it('compares today against yesterday and labels the window', async () => {
74 +
    const user = await createTestUser(db)
75 +
    const ref = new Date()
76 +
    const { start } = appDayWindow(ref)
77 +
    const { start: yesterday } = appDayWindow(new Date(start.getTime() - 1))
78 +

79 +
    await createMood(user.id, 8, new Date(start.getTime() + HOUR))
80 +
    await createMood(user.id, 6, new Date(start.getTime() + 2 * HOUR))
81 +
    await createMood(user.id, 3, new Date(yesterday.getTime() + HOUR))
82 +
    await createMood(user.id, 5, new Date(yesterday.getTime() + 2 * HOUR))
83 +

84 +
    await processDailyEnergy({
85 +
      userId: user.id,
86 +
      periodStart: ref.toISOString(),
87 +
      periodEnd: ref.toISOString(),
88 +
    })
89 +

90 +
    const insight = await db.insight.findFirst({
91 +
      where: { userId: user.id, type: 'DAILY_ENERGY' },
92 +
    })
93 +

94 +
    expect(insight).not.toBeNull()
95 +
    expect(insight!.metadata).toMatchObject({
96 +
      avgEnergy: 7,
97 +
      yesterdayAvg: 4,
98 +
      diffEnergy: 3,
99 +
      windowLabel: 'hoje',
100 +
    })
101 +
  })
102 +

103 +
  it('omits the comparison when yesterday has no mood entry', async () => {
104 +
    const user = await createTestUser(db)
105 +
    const ref = new Date()
106 +
    const { start } = appDayWindow(ref)
107 +

108 +
    await createMood(user.id, 6, new Date(start.getTime() + HOUR))
109 +

110 +
    await processDailyEnergy({
111 +
      userId: user.id,
112 +
      periodStart: ref.toISOString(),
113 +
      periodEnd: ref.toISOString(),
114 +
    })
115 +

116 +
    const insight = await db.insight.findFirst({
117 +
      where: { userId: user.id, type: 'DAILY_ENERGY' },
118 +
    })
119 +

120 +
    expect(insight).not.toBeNull()
121 +
    expect(insight!.metadata).toMatchObject({
122 +
      avgEnergy: 6,
123 +
      yesterdayAvg: null,
124 +
      diffEnergy: null,
125 +
    })
126 +
  })
72 127
})
73 128

74 129
describe('processDailySleep', () => {
75 130
  it('sums today and compares against yesterday', async () => {
76 131
    const user = await createTestUser(db)
122 177
    const insight = await db.insight.findFirst({
123 178
      where: { userId: user.id, type: 'DAILY_SLEEP' },
124 179
    })
125 180

126 181
    expect(insight).toBeNull()
182 +
  })
183 +

184 +
  it('labels the window and names the extra nap sessions', async () => {
185 +
    const user = await createTestUser(db)
186 +
    const ref = new Date()
187 +
    const { start: today } = appDayWindow(ref)
188 +

189 +
    await db.sleepRecord.create({
190 +
      data: { userId: user.id, date: today, average: 5 },
191 +
    })
192 +
    await db.sleepRecord.create({
193 +
      data: { userId: user.id, date: today, average: 2 },
194 +
    })
195 +

196 +
    await processDailySleep({
197 +
      userId: user.id,
198 +
      periodStart: ref.toISOString(),
199 +
      periodEnd: ref.toISOString(),
200 +
    })
201 +

202 +
    const insight = await db.insight.findFirst({
203 +
      where: { userId: user.id, type: 'DAILY_SLEEP' },
204 +
    })
205 +

206 +
    expect(insight).not.toBeNull()
207 +
    expect(insight!.metadata).toMatchObject({
208 +
      totalHours: 7,
209 +
      sessionCount: 2,
210 +
      napStr: 'incluindo 1 cochilo',
211 +
      windowLabel: 'hoje',
212 +
    })
213 +
  })
214 +

215 +
  it('omits the comparison when yesterday has no sleep record', async () => {
216 +
    const user = await createTestUser(db)
217 +
    const ref = new Date()
218 +
    const { start: today } = appDayWindow(ref)
219 +

220 +
    await db.sleepRecord.create({
221 +
      data: { userId: user.id, date: today, average: 6 },
222 +
    })
223 +

224 +
    await processDailySleep({
225 +
      userId: user.id,
226 +
      periodStart: ref.toISOString(),
227 +
      periodEnd: ref.toISOString(),
228 +
    })
229 +

230 +
    const insight = await db.insight.findFirst({
231 +
      where: { userId: user.id, type: 'DAILY_SLEEP' },
232 +
    })
233 +

234 +
    expect(insight).not.toBeNull()
235 +
    expect(insight!.metadata).toMatchObject({
236 +
      totalHours: 6,
237 +
      diffMinutes: null,
238 +
      diffStr: '',
239 +
    })
127 240
  })
128 241
})