feat(home): implement home summary feature with routes and use case

- Added home routes and corresponding route handler for fetching home summary.
- Created GetHomeSummary use case to aggregate user data including groups, attendees, and upcoming payments.
- Introduced HomeSummaryDto and related schemas for data validation.
- Implemented nextClassOccurrence function to determine the next class based on user timezone.
- Added tests for home summary functionality and next class occurrence logic.
This commit is contained in:
Jose Selesan
2026-09-23 10:35:24 -03:00
parent 17dca1ad89
commit f1e0fc5aae
13 changed files with 1037 additions and 6 deletions

View File

@@ -14,6 +14,7 @@ import { attendeesRoutes } from './modules/attendees';
import { authRoutes } from './modules/auth';
import { groupsRoutes } from './modules/groups';
import { healthCheckRoutes } from './modules/health-check';
import { homeRoutes } from './modules/home';
import { invitationsRoutes } from './modules/invitations';
import { onboardingRoutes } from './modules/onboarding';
import { paymentsRoutes } from './modules/payments';
@@ -36,6 +37,7 @@ api.route('/auth', authRoutes);
api.route('/health', healthCheckRoutes);
api.route('/invitations', invitationsRoutes);
api.route('/groups', groupsRoutes);
api.route('/home', homeRoutes);
api.route('/onboarding', onboardingRoutes);
api.route('/attendees', attendeesRoutes);
api.route('/payments', paymentsRoutes);

View File

@@ -0,0 +1,23 @@
import type { HomeQuery } from '@gruperly/shared';
import { HomeQuerySchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { GetHomeSummary } from './use-case';
const route = new Hono();
route.get('/', validate.query(HomeQuerySchema), async (c) => {
const user = c.get('user');
if (!user) {
return problemJson(c, unauthorizedProblem(c.req.path));
}
const query = c.req.valid('query') as HomeQuery;
const useCase = new GetHomeSummary();
const result = await useCase.execute(user.id, query);
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,134 @@
import type { PrismaClient } from '@generated/prisma/client';
import type {
HomeQuery,
HomeSummary,
NextClass,
ProblemDetails,
Result,
} from '@gruperly/shared';
import { ok } from '@gruperly/shared';
import prisma from '@/lib/prisma';
import { buildGroupWhereForUser } from '@/modules/groups/lib/helpers';
import { DEFAULT_TIME_ZONE, nextClassOccurrence } from '../../lib/scheduler';
export const UPCOMING_PAYMENTS_LIMIT = 5;
type HomeSummaryDeps = {
db?: Pick<PrismaClient, 'group' | 'payment'>;
now?: Date;
timeZone?: string;
};
type GroupSummaryRecord = {
id: string;
name: string;
days: NextClass['days'];
time: string | null;
_count: { attendees: number };
};
export class GetHomeSummary {
constructor(private readonly deps: HomeSummaryDeps = {}) {}
async execute(userId: string, query: HomeQuery = {}): Promise<Result<HomeSummary, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const now = this.deps.now ?? new Date();
const timeZone = this.deps.timeZone ?? query.timeZone ?? DEFAULT_TIME_ZONE;
const groups = await db.group.findMany({
where: buildGroupWhereForUser(userId),
select: {
id: true,
name: true,
days: true,
time: true,
_count: { select: { attendees: true } },
},
});
if (groups.length === 0) {
return ok({
stats: { groups: 0, attendees: 0, pendingPayments: 0, pendingAmount: 0 },
nextClass: null,
upcomingPayments: [],
});
}
const groupIds = groups.map((group) => group.id);
const attendees = groups.reduce((sum, group) => sum + group._count.attendees, 0);
const [pendingAggregate, upcomingPayments] = await Promise.all([
db.payment.aggregate({
where: { groupId: { in: groupIds }, status: { in: ['PENDING', 'OVERDUE'] } },
_count: true,
_sum: { amount: true },
}),
db.payment.findMany({
where: { groupId: { in: groupIds }, status: { in: ['PENDING', 'OVERDUE'] } },
orderBy: [{ dueDate: 'asc' as const }],
take: UPCOMING_PAYMENTS_LIMIT,
include: {
group: { select: { name: true } },
attendee: { select: { fullName: true } },
},
}),
]);
return ok({
stats: {
groups: groups.length,
attendees,
pendingPayments: pendingAggregate._count,
pendingAmount: pendingAggregate._sum.amount
? Number(pendingAggregate._sum.amount.toString())
: 0,
},
nextClass: this.findNextClass(groups, now, timeZone),
upcomingPayments: upcomingPayments.map((payment) => ({
id: payment.id,
amount: Number(payment.amount.toString()),
currency: payment.currency,
dueDate: payment.dueDate.toISOString(),
status: payment.status,
groupName: payment.group.name,
attendeeName: payment.attendee.fullName,
})),
});
}
private findNextClass(
groups: GroupSummaryRecord[],
now: Date,
timeZone: string,
): NextClass | null {
let best: NextClass | null = null;
for (const group of groups) {
if (!group.days || group.days.length === 0 || !group.time) {
continue;
}
const occurrence = nextClassOccurrence({
days: group.days,
time: group.time,
now,
timeZone,
});
if (!occurrence) {
continue;
}
if (best && occurrence.occurrenceAt.getTime() >= new Date(best.occurrenceAt).getTime()) {
continue;
}
best = {
groupId: group.id,
name: group.name,
days: group.days,
time: group.time,
occurrenceAt: occurrence.occurrenceAt.toISOString(),
isNow: occurrence.isNow,
};
}
return best;
}
}

View File

@@ -0,0 +1 @@
export { default as homeRoutes } from './routes';

View File

@@ -0,0 +1,146 @@
import type { WeekDay } from '@gruperly/shared';
export const DEFAULT_TIME_ZONE = process.env.APP_TIMEZONE ?? 'America/Mexico_City';
// Ventana (en horas) durante la que una clase ya arrancada se muestra como "en curso".
export const CLASS_WINDOW_HOURS = 2;
export const CLASS_WINDOW_MS = CLASS_WINDOW_HOURS * 60 * 60 * 1000;
const WEEKDAY_TO_DOW: Record<WeekDay, number> = {
SUNDAY: 0,
MONDAY: 1,
TUESDAY: 2,
WEDNESDAY: 3,
THURSDAY: 4,
FRIDAY: 5,
SATURDAY: 6,
};
export type NextClassOccurrence = {
occurrenceAt: Date;
isNow: boolean;
};
// Convierte un tiempo de "pared" (wall clock) expresado en `timeZone` al instante absoluto.
function zonedWallClockToUtc(wall: string, timeZone: string): Date {
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(wall);
if (!match) {
throw new Error(`Invalid wall clock: ${wall}`);
}
const [, year, month, day, hour, minute] = match;
const asUtc = new Date(
Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute)),
);
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
.formatToParts(asUtc)
.reduce<Record<string, string>>((acc, part) => {
acc[part.type] = part.value;
return acc;
}, {});
const tzRepresentation = new Date(
Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute),
Number(parts.second),
),
);
const offsetMs = asUtc.getTime() - tzRepresentation.getTime();
return new Date(asUtc.getTime() + offsetMs);
}
function wallDateInTimeZone(now: Date, timeZone: string): { year: number; month: number; day: number } {
const wall = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
.formatToParts(now)
.reduce<Record<string, string>>((acc, part) => {
acc[part.type] = part.value;
return acc;
}, {});
return {
year: Number(wall.year),
month: Number(wall.month),
day: Number(wall.day),
};
}
export function nextClassOccurrence(opt: {
days: WeekDay[];
time: string;
now: Date;
timeZone: string;
}): NextClassOccurrence | null {
const { days, time, now, timeZone } = opt;
if (days.length === 0 || !time) {
return null;
}
const [hour, minute] = time.split(':').map(Number);
if (
Number.isNaN(hour) ||
Number.isNaN(minute) ||
hour < 0 ||
hour > 23 ||
minute < 0 ||
minute > 59
) {
return null;
}
const dowSet = new Set(days.map((day) => WEEKDAY_TO_DOW[day]));
const { year, month, day } = wallDateInTimeZone(now, timeZone);
let best: NextClassOccurrence | null = null;
for (let offset = 0; offset <= 7; offset++) {
const candidateDay = new Date(Date.UTC(year, month - 1, day + offset, 0, 0, 0));
const dow = candidateDay.getUTCDay();
if (!dowSet.has(dow)) {
continue;
}
const wall = `${candidateDay.toISOString().slice(0, 10)}T${pad(hour)}:${pad(minute)}`;
const candidate = zonedWallClockToUtc(wall, timeZone);
if (offset === 0) {
if (candidate.getTime() > now.getTime()) {
best = { occurrenceAt: candidate, isNow: false };
break;
}
if (now.getTime() >= candidate.getTime() && now.getTime() - candidate.getTime() < CLASS_WINDOW_MS) {
return { occurrenceAt: candidate, isNow: true };
}
continue;
}
if (!best || candidate.getTime() < best.occurrenceAt.getTime()) {
best = { occurrenceAt: candidate, isNow: false };
}
}
return best;
}
function pad(value: number): string {
return String(value).padStart(2, '0');
}

View File

@@ -0,0 +1,8 @@
import { Hono } from 'hono';
import getSummaryRoute from './features/get-summary/route';
const routes = new Hono();
routes.route('/', getSummaryRoute);
export default routes;

View File

@@ -0,0 +1,112 @@
import { describe, expect, it } from 'bun:test';
import { CLASS_WINDOW_HOURS, nextClassOccurrence } from '@/modules/home/lib/scheduler';
const TZ = 'America/Mexico_City';
describe('nextClassOccurrence', () => {
it('devuelve la próxima clase de hoy cuando aún no arrancó', () => {
const now = new Date('2026-09-23T18:00:00.000Z'); // miércoles 12:00 hora CDMX
const result = nextClassOccurrence({
days: ['WEDNESDAY'],
time: '18:00',
now,
timeZone: TZ,
});
expect(result).toEqual({
occurrenceAt: new Date('2026-09-24T00:00:00.000Z'),
isNow: false,
});
});
it('marca en curso si la clase arrancó hace menos de la ventana', () => {
const now = new Date('2026-09-24T01:00:00.000Z'); // miércoles 19:00 hora CDMX (1 h tras la clase)
const result = nextClassOccurrence({
days: ['WEDNESDAY'],
time: '18:00',
now,
timeZone: TZ,
});
expect(result).toEqual({
occurrenceAt: new Date('2026-09-24T00:00:00.000Z'),
isNow: true,
});
});
it('salta a la próxima semana si la clase ya pasó la ventana', () => {
const now = new Date('2026-09-24T03:00:00.000Z'); // miércoles 21:00 hora CDMX (3 h tras la clase)
const result = nextClassOccurrence({
days: ['WEDNESDAY'],
time: '18:00',
now,
timeZone: TZ,
});
expect(result).toEqual({
occurrenceAt: new Date('2026-10-01T00:00:00.000Z'),
isNow: false,
});
});
it('elige la ocurrencia más próxima entre varios días', () => {
const now = new Date('2026-09-23T18:00:00.000Z'); // miércoles 12:00 hora CDMX
const result = nextClassOccurrence({
days: ['MONDAY', 'WEDNESDAY', 'FRIDAY'],
time: '18:00',
now,
timeZone: TZ,
});
expect(result).toEqual({
occurrenceAt: new Date('2026-09-24T00:00:00.000Z'),
isNow: false,
});
});
it('cruza de mes sin problema', () => {
const now = new Date('2026-09-29T18:00:00.000Z'); // martes 12:00 hora CDMX (último día de septiembre es miércoles 30)
const result = nextClassOccurrence({
days: ['THURSDAY'],
time: '09:00',
now,
timeZone: TZ,
});
expect(result).toEqual({
occurrenceAt: new Date('2026-10-01T15:00:00.000Z'),
isNow: false,
});
});
it('devuelve null si no hay días de clase', () => {
const result = nextClassOccurrence({
days: [],
time: '18:00',
now: new Date('2026-09-23T18:00:00.000Z'),
timeZone: TZ,
});
expect(result).toBeNull();
});
it('devuelve null si la hora es inválida', () => {
const result = nextClassOccurrence({
days: ['WEDNESDAY'],
time: '99:99',
now: new Date('2026-09-23T18:00:00.000Z'),
timeZone: TZ,
});
expect(result).toBeNull();
});
it('expone el tamaño de la ventana de "en curso"', () => {
expect(CLASS_WINDOW_HOURS).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,277 @@
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
const db = {
group: {
findMany: mock(),
},
payment: {
aggregate: mock(),
findMany: mock(),
},
};
mock.module('@/lib/prisma', () => ({
default: db,
getPrismaClient: mock(),
UnitOfWork: class {
executeResult = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
execute = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
},
}));
import prisma from '@/lib/prisma';
import { homeRoutes } from '@/modules/home';
import { GetHomeSummary } from '@/modules/home/features/get-summary/use-case';
const TZ = 'America/Mexico_City';
const userId = 'user-1';
const groupA = {
id: 'g1',
name: 'Cuadrilla A',
days: ['WEDNESDAY'],
time: '18:00',
price: 500,
billingType: 'MONTHLY',
dueDay: 5,
capacity: 8,
_count: { attendees: 3 },
};
const groupB = {
id: 'g2',
name: 'Cuadrilla B',
days: [],
time: null,
price: null,
billingType: null,
dueDay: null,
capacity: null,
_count: { attendees: 2 },
};
const madeUpAmount = (value: string) => ({ toString: () => value });
function makeApp(userValue: unknown) {
const app = new Hono();
app.use('*', async (c, next) => {
c.set('user', userValue as never);
await next();
});
app.route('/home', homeRoutes);
return app;
}
describe('GetHomeSummary', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('agrega stats, próxima clase y próximos cobros del usuario', async () => {
prisma.group.findMany.mockResolvedValue([groupB, groupA]);
prisma.payment.aggregate.mockResolvedValue({
_count: 4,
_sum: { amount: madeUpAmount('2100') },
});
prisma.payment.findMany.mockResolvedValue([
{
id: 'p1',
amount: madeUpAmount('500'),
currency: 'MXN',
dueDate: new Date('2026-09-25T12:00:00.000Z'),
status: 'PENDING',
group: { name: 'Cuadrilla A' },
attendee: { fullName: 'Ana García' },
},
]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T18:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toEqual({
stats: { groups: 2, attendees: 5, pendingPayments: 4, pendingAmount: 2100 },
nextClass: {
groupId: 'g1',
name: 'Cuadrilla A',
days: ['WEDNESDAY'],
time: '18:00',
occurrenceAt: '2026-09-24T00:00:00.000Z',
isNow: false,
},
upcomingPayments: [
{
id: 'p1',
amount: 500,
currency: 'MXN',
dueDate: '2026-09-25T12:00:00.000Z',
status: 'PENDING',
groupName: 'Cuadrilla A',
attendeeName: 'Ana García',
},
],
});
const where = {
OR: [{ createdById: userId }, { members: { some: { userId } } }],
};
expect(prisma.group.findMany).toHaveBeenCalledWith({ where, select: expect.any(Object) });
expect(prisma.payment.aggregate).toHaveBeenCalledWith({
where: { groupId: { in: ['g2', 'g1'] }, status: { in: ['PENDING', 'OVERDUE'] } },
_count: true,
_sum: { amount: true },
});
expect(prisma.payment.findMany).toHaveBeenCalledWith({
where: { groupId: { in: ['g2', 'g1'] }, status: { in: ['PENDING', 'OVERDUE'] } },
orderBy: [{ dueDate: 'asc' }],
take: 5,
include: { group: { select: { name: true } }, attendee: { select: { fullName: true } } },
});
});
it('marca como en curso la clase que ya arrancó', async () => {
prisma.group.findMany.mockResolvedValue([groupA]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-24T01:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass).toMatchObject({
groupId: 'g1',
occurrenceAt: '2026-09-24T00:00:00.000Z',
isNow: true,
});
});
it('elige el grupo con la próxima clase más cercana', async () => {
prisma.group.findMany.mockResolvedValue([
groupA,
{
id: 'g3',
name: 'Cuadrilla C',
days: ['THURSDAY'],
time: '09:00',
price: null,
billingType: null,
dueDay: null,
capacity: null,
_count: { attendees: 0 },
},
]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T18:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass?.groupId).toBe('g1');
expect(result.value.nextClass?.occurrenceAt).toBe('2026-09-24T00:00:00.000Z');
});
it('devuelve nextClass null si ningún grupo tiene horario', async () => {
prisma.group.findMany.mockResolvedValue([groupB]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T18:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass).toBeNull();
expect(prisma.payment.aggregate).toHaveBeenCalled();
expect(prisma.payment.findMany).toHaveBeenCalled();
});
it('computa la próxima clase en la zona horaria del cliente', async () => {
prisma.group.findMany.mockResolvedValue([
{ ...groupA, days: ['WEDNESDAY'], time: '10:00' },
]);
// 10:23 en Buenos Aires (UTC-3). La clase de las 10:00 ya arrancó.
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T13:23:00.000Z') });
const result = await useCase.execute(userId, { timeZone: 'America/Argentina/Buenos_Aires' });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass).toMatchObject({
groupId: 'g1',
occurrenceAt: '2026-09-23T13:00:00.000Z',
isNow: true,
});
});
it('usa la zona por defecto si no se envía una', async () => {
prisma.group.findMany.mockResolvedValue([
{ ...groupA, days: ['WEDNESDAY'], time: '10:00' },
]);
// 10:23 en Buenos Aires, pero la zona por defecto es CDMX → las 07:23 CDMX (aún no empieza).
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T13:23:00.000Z') });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass).toMatchObject({
occurrenceAt: '2026-09-23T16:00:00.000Z',
isNow: false,
});
});
it('devuelve ceros y listas vacías cuando el usuario no tiene grupos', async () => {
prisma.group.findMany.mockResolvedValue([]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T18:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toEqual({
stats: { groups: 0, attendees: 0, pendingPayments: 0, pendingAmount: 0 },
nextClass: null,
upcomingPayments: [],
});
expect(prisma.payment.aggregate).not.toHaveBeenCalled();
expect(prisma.payment.findMany).not.toHaveBeenCalled();
});
});
describe('home routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('devuelve el resumen del home autenticado', async () => {
prisma.group.findMany.mockResolvedValue([]);
const res = await makeApp({ id: userId }).request('/home');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
stats: { groups: 0, attendees: 0, pendingPayments: 0, pendingAmount: 0 },
nextClass: null,
upcomingPayments: [],
});
});
it('rechaza el home sin sesión', async () => {
const res = await makeApp(null).request('/home');
expect(res.status).toBe(401);
expect(await res.json()).toMatchObject({ code: 'unauthorized' });
});
it('rechaza una zona horaria inválida', async () => {
const res = await makeApp({ id: userId }).request('/home?timeZone=No/Es_Una_Zona');
expect(res.status).toBe(400);
});
});