api/insights: the insights read can ask for a specific day
The read endpoint now takes an optional day and filters on the insight's period start, and an unqualified daily request resolves to the server's app-timezone today Previously a daily request returned whichever DAILY_* row was regenerated last, so a day with nothing logged still showed yesterday's numbers under the today's comparision heading
Pedro Lucas Porcellis porcellis@eletrotupi.com · 12 days ago
b36ca93521cd90a436cca219f26ba8cb906448f4 Browse filesParents
de0605fChanged files
5
Additions
+189
Deletions
-10
api/src/controllers/insights.ts
1 1
import { Response, NextFunction } from "express";
2 2
import { AuthenticatedRequest } from "@app/middleware/auth";
3 +
import { InsightType } from "@prisma/client";
3 4
import { getInsightsByUserId } from "@app/services/insight.service";
5 +
import { parseAppDay } from "@app/lib/queue/processors/insights/utils";
4 6
import { InsightsQuerySchema } from "@app/schemas";
5 7
import { formatValidationError } from "@app/lib/errors/validationError";
6 8
9 +
const DAILY_INSIGHT_TYPES = new Set<InsightType>([
10 +
InsightType.DAILY_ENERGY,
11 +
InsightType.DAILY_SLEEP,
12 +
]);
13 +
14 +
// A day with no explicit date means "today" for the daily insight types, so the
15 +
// client never computes a day boundary; other types stay unfiltered
16 +
function resolveInsightDay(
17 +
type?: InsightType,
18 +
day?: string,
19 +
): Date | undefined {
20 +
if (day) return parseAppDay(day);
21 +
22 +
return type && DAILY_INSIGHT_TYPES.has(type) ? new Date() : undefined;
23 +
}
24 +
7 25
export const InsightsController = {
8 26
index: async (
9 27
req: AuthenticatedRequest,
10 28
res: Response,
11 29
next: NextFunction,
15 33
16 34
if (!parsedQuery.success) {
17 35
return res.status(400).json(formatValidationError(parsedQuery.error));
18 36
}
19 37
38 +
const { type, period, limit, day } = parsedQuery.data;
39 +
20 40
const insights = await getInsightsByUserId(req.userId!, {
21 -
type: parsedQuery.data.type,
22 -
period: parsedQuery.data.period,
23 -
limit: parsedQuery.data.limit,
41 +
type,
42 +
period,
43 +
limit,
44 +
day: resolveInsightDay(type, day),
24 45
});
25 46
26 47
return res.status(200).json({ insights });
27 48
} catch (err) {
28 49
next(err);
29 50
}
30 51
},
31 52
};
api/src/lib/queue/processors/insights/utils.ts
1 1
import { tz } from "@date-fns/tz";
2 -
import { addDays, startOfDay, subDays } from "date-fns";
2 +
import { addDays, parse, 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);
26 26
// Start of the app-timezone day immediately before the one containing `date`
27 27
export function previousAppDay(date: Date = new Date()): Date {
28 28
return startOfDay(subDays(date, 1, { in: APP_TIMEZONE }), {
29 29
in: APP_TIMEZONE,
30 30
});
31 +
}
32 +
33 +
// Start of the app-timezone day named by a YYYY-MM-DD string
34 +
export function parseAppDay(day: string): Date {
35 +
return parse(day, "yyyy-MM-dd", new Date(), { in: APP_TIMEZONE });
31 36
}
32 37
33 38
// The app-timezone day containing `date`, as a half-open [start, end) window
34 39
export function appDayWindow(date: Date = new Date()): {
35 40
start: Date;
49 54
} {
50 55
const { start, end } = appDayWindow(date);
51 56
52 57
return { periodStart: start.toISOString(), periodEnd: end.toISOString() };
53 58
}
api/src/schemas/insight.schema.ts
1 1
import { z } from 'zod';
2 +
import { isMatch } from 'date-fns';
2 3
import { InsightPeriod, InsightType } from '@prisma/client';
3 4
4 5
export const InsightsQuerySchema = z.object({
5 6
type: z.nativeEnum(InsightType).optional(),
6 7
period: z.nativeEnum(InsightPeriod).optional(),
7 8
limit: z.coerce.number().int().positive().max(100).optional(),
9 +
day: z
10 +
.string()
11 +
.regex(/^\d{4}-\d{2}-\d{2}$/)
12 +
.refine((value) => isMatch(value, 'yyyy-MM-dd'))
13 +
.optional(),
8 14
});
9 15
10 16
export type InsightsQueryInput = z.infer<typeof InsightsQuerySchema>;
api/src/services/insight.service.ts
1 1
import { prisma } from "@app/lib/prisma";
2 -
import { Insight, InsightType, InsightPeriod } from "@prisma/client";
2 +
import { appDayWindow } from "@app/lib/queue/processors/insights/utils";
3 +
import { Prisma, Insight, InsightType, InsightPeriod } from "@prisma/client";
3 4
4 5
export const getInsightsByUserId = async (
5 6
userId: number,
6 7
params?: {
7 8
type?: InsightType;
8 9
period?: InsightPeriod;
9 10
limit?: number;
11 +
day?: Date;
10 12
},
11 13
): Promise<Insight[]> => {
14 +
const where: Prisma.InsightWhereInput = {
15 +
userId,
16 +
...(params?.type && { type: params.type }),
17 +
...(params?.period && { period: params.period }),
18 +
};
19 +
20 +
if (params?.day) {
21 +
const { start, end } = appDayWindow(params.day);
22 +
where.periodStart = { gte: start, lt: end };
23 +
}
24 +
12 25
return prisma.insight.findMany({
13 -
where: {
14 -
userId,
15 -
...(params?.type && { type: params.type }),
16 -
...(params?.period && { period: params.period }),
17 -
},
26 +
where,
18 27
orderBy: { generatedAt: "desc" },
19 28
take: params?.limit ?? 10,
20 29
});
21 30
};
22 31
27 36
return prisma.insight.findFirst({
28 37
where: { userId, type },
29 38
orderBy: { generatedAt: "desc" },
30 39
});
31 40
};
api/tests/insights/index.test.ts
1 1
import { describe, it, expect, afterAll, beforeEach } from 'vitest'
2 2
import request from 'supertest'
3 +
import { format } from 'date-fns'
4 +
import { tz } from '@date-fns/tz'
3 5
import { createApp } from '@app/createApp'
4 6
import { InsightPeriod, InsightType } from '@prisma/client'
7 +
import { appDayWindow } from '@app/lib/queue/processors/insights/utils'
5 8
import {
6 9
buildPrisma,
7 10
cleanupTestDb,
8 11
createTestUser,
9 12
generateTestToken,
10 13
} from '../test-helper'
11 14
12 15
const app = createApp()
13 16
const db = buildPrisma()
17 +
const SP = tz('America/Sao_Paulo')
14 18
15 19
afterAll(() => db.$disconnect())
16 20
beforeEach(() => cleanupTestDb(db))
21 +
22 +
// The YYYY-MM-DD key the read endpoint expects for the app day containing date
23 +
function appDayKey(date: Date) {
24 +
return format(date, 'yyyy-MM-dd', { in: SP })
25 +
}
17 26
18 27
let seedCounter = 0
19 28
// The schema is unique on (userId, type, periodStart), so give every seeded
20 29
// row a distinct periodStart unless the test asks for a specific one
21 30
function uniquePeriodStart() {
160 169
})
161 170
162 171
it('returns 401 without a token', async () => {
163 172
const res = await request(app).get('/insights')
164 173
expect(res.status).toBe(401)
174 +
})
175 +
176 +
it('resolves an unqualified daily request to today', async () => {
177 +
const user = await createTestUser(db)
178 +
const token = generateTestToken(user.id, user.email)
179 +
const { start: today } = appDayWindow()
180 +
const { start: yesterday } = appDayWindow(new Date(today.getTime() - 1))
181 +
182 +
await seedInsight(user.id, {
183 +
type: InsightType.DAILY_ENERGY,
184 +
period: InsightPeriod.DAILY,
185 +
periodStart: yesterday,
186 +
title: 'energy-yesterday',
187 +
})
188 +
await seedInsight(user.id, {
189 +
type: InsightType.DAILY_ENERGY,
190 +
period: InsightPeriod.DAILY,
191 +
periodStart: today,
192 +
title: 'energy-today',
193 +
})
194 +
await seedInsight(user.id, {
195 +
type: InsightType.DAILY_SLEEP,
196 +
period: InsightPeriod.DAILY,
197 +
periodStart: yesterday,
198 +
title: 'sleep-yesterday',
199 +
})
200 +
await seedInsight(user.id, {
201 +
type: InsightType.DAILY_SLEEP,
202 +
period: InsightPeriod.DAILY,
203 +
periodStart: today,
204 +
title: 'sleep-today',
205 +
})
206 +
207 +
const energy = await request(app)
208 +
.get('/insights?type=DAILY_ENERGY')
209 +
.set('Authorization', `Bearer ${token}`)
210 +
const sleep = await request(app)
211 +
.get('/insights?type=DAILY_SLEEP')
212 +
.set('Authorization', `Bearer ${token}`)
213 +
214 +
expect(energy.status).toBe(200)
215 +
expect(energy.body.insights.map((i: any) => i.title)).toEqual([
216 +
'energy-today',
217 +
])
218 +
expect(sleep.status).toBe(200)
219 +
expect(sleep.body.insights.map((i: any) => i.title)).toEqual(['sleep-today'])
220 +
})
221 +
222 +
it('filters the insight to an explicit day', async () => {
223 +
const user = await createTestUser(db)
224 +
const token = generateTestToken(user.id, user.email)
225 +
const { start: today } = appDayWindow()
226 +
const { start: yesterday } = appDayWindow(new Date(today.getTime() - 1))
227 +
228 +
await seedInsight(user.id, {
229 +
type: InsightType.DAILY_ENERGY,
230 +
period: InsightPeriod.DAILY,
231 +
periodStart: yesterday,
232 +
title: 'energy-yesterday',
233 +
})
234 +
await seedInsight(user.id, {
235 +
type: InsightType.DAILY_ENERGY,
236 +
period: InsightPeriod.DAILY,
237 +
periodStart: today,
238 +
title: 'energy-today',
239 +
})
240 +
241 +
const res = await request(app)
242 +
.get(`/insights?type=DAILY_ENERGY&day=${appDayKey(yesterday)}`)
243 +
.set('Authorization', `Bearer ${token}`)
244 +
245 +
expect(res.status).toBe(200)
246 +
expect(res.body.insights.map((i: any) => i.title)).toEqual([
247 +
'energy-yesterday',
248 +
])
249 +
})
250 +
251 +
it('returns an empty list for a day with no generated insight', async () => {
252 +
const user = await createTestUser(db)
253 +
const token = generateTestToken(user.id, user.email)
254 +
const { start: today } = appDayWindow()
255 +
const { start: yesterday } = appDayWindow(new Date(today.getTime() - 1))
256 +
257 +
await seedInsight(user.id, {
258 +
type: InsightType.DAILY_ENERGY,
259 +
period: InsightPeriod.DAILY,
260 +
periodStart: today,
261 +
title: 'energy-today',
262 +
})
263 +
264 +
const res = await request(app)
265 +
.get(`/insights?type=DAILY_ENERGY&day=${appDayKey(yesterday)}`)
266 +
.set('Authorization', `Bearer ${token}`)
267 +
268 +
expect(res.status).toBe(200)
269 +
expect(res.body.insights).toEqual([])
270 +
})
271 +
272 +
it('returns an empty list when today has no daily insight', async () => {
273 +
const user = await createTestUser(db)
274 +
const token = generateTestToken(user.id, user.email)
275 +
const { start: today } = appDayWindow()
276 +
const { start: yesterday } = appDayWindow(new Date(today.getTime() - 1))
277 +
278 +
await seedInsight(user.id, {
279 +
type: InsightType.DAILY_ENERGY,
280 +
period: InsightPeriod.DAILY,
281 +
periodStart: yesterday,
282 +
title: 'energy-yesterday',
283 +
})
284 +
285 +
const res = await request(app)
286 +
.get('/insights?type=DAILY_ENERGY')
287 +
.set('Authorization', `Bearer ${token}`)
288 +
289 +
expect(res.status).toBe(200)
290 +
expect(res.body.insights).toEqual([])
291 +
})
292 +
293 +
it('returns 400 for a malformed day', async () => {
294 +
const user = await createTestUser(db)
295 +
const token = generateTestToken(user.id, user.email)
296 +
297 +
const res = await request(app)
298 +
.get('/insights?day=2026-13-40')
299 +
.set('Authorization', `Bearer ${token}`)
300 +
301 +
expect(res.status).toBe(400)
302 +
expect(res.body.fields).toBeTruthy()
165 303
})
166 304
})