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:
@@ -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);
|
||||
|
||||
23
apps/backend/src/modules/home/features/get-summary/route.ts
Normal file
23
apps/backend/src/modules/home/features/get-summary/route.ts
Normal 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;
|
||||
134
apps/backend/src/modules/home/features/get-summary/use-case.ts
Normal file
134
apps/backend/src/modules/home/features/get-summary/use-case.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1
apps/backend/src/modules/home/index.ts
Normal file
1
apps/backend/src/modules/home/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as homeRoutes } from './routes';
|
||||
146
apps/backend/src/modules/home/lib/scheduler.ts
Normal file
146
apps/backend/src/modules/home/lib/scheduler.ts
Normal 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');
|
||||
}
|
||||
8
apps/backend/src/modules/home/routes.ts
Normal file
8
apps/backend/src/modules/home/routes.ts
Normal 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;
|
||||
112
apps/backend/test/home-scheduler.test.ts
Normal file
112
apps/backend/test/home-scheduler.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
277
apps/backend/test/home.test.ts
Normal file
277
apps/backend/test/home.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
GroupList,
|
||||
GroupWaitlistEntryDto,
|
||||
GroupWaitlistList,
|
||||
HomeSummaryDto,
|
||||
InviteTokenResult,
|
||||
JoinGroupViaInvite,
|
||||
JoinGroupViaInviteResult,
|
||||
@@ -88,6 +89,18 @@ export const createGroup = (payload: CreateFirstGroup) =>
|
||||
|
||||
export const getGroups = () => apiFetch<GroupList>('/api/v1/groups')
|
||||
|
||||
export const getHomeSummary = () => {
|
||||
let timeZone: string | undefined
|
||||
try {
|
||||
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
} catch {
|
||||
// Sin zona horaria disponible: el backend usa su valor por defecto.
|
||||
timeZone = undefined
|
||||
}
|
||||
const params = timeZone ? `?timeZone=${encodeURIComponent(timeZone)}` : ''
|
||||
return apiFetch<HomeSummaryDto>(`/api/v1/home${params}`)
|
||||
}
|
||||
|
||||
export const getGroup = (groupId: string) =>
|
||||
apiFetch<GroupDto>(`/api/v1/groups/${groupId}`)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BillingType, WeekDay } from '@gruperly/shared'
|
||||
import type { BillingType, PaymentDto, WeekDay } from '@gruperly/shared'
|
||||
|
||||
export const WEEK_DAY_FULL_LABELS: Record<WeekDay, string> = {
|
||||
MONDAY: 'Lunes',
|
||||
@@ -15,6 +15,35 @@ export const BILLING_LABELS: Record<BillingType, string> = {
|
||||
PER_CLASS: 'Por clase',
|
||||
}
|
||||
|
||||
export const PAYMENT_STATUS_LABELS: Record<PaymentDto['status'], string> = {
|
||||
PENDING: 'Pendiente',
|
||||
PAID: 'Pagado',
|
||||
OVERDUE: 'Vencido',
|
||||
CANCELLED: 'Cancelado',
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000
|
||||
|
||||
function startOfDay(date: Date): number {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
|
||||
}
|
||||
|
||||
export function formatRelativeDateTime(iso: string, now: Date = new Date()): string {
|
||||
const date = new Date(iso)
|
||||
const time = date.toLocaleTimeString('es-MX', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
const diffDays = Math.round((startOfDay(date) - startOfDay(now)) / DAY_MS)
|
||||
|
||||
if (diffDays === 0) return `Hoy · ${time}`
|
||||
if (diffDays === 1) return `Mañana · ${time}`
|
||||
|
||||
const label = date.toLocaleDateString('es-MX', { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
return `${label} · ${time}`
|
||||
}
|
||||
|
||||
export function formatSchedule(days: readonly WeekDay[], time: string | null): string {
|
||||
const dayNames = days.map((day) => WEEK_DAY_FULL_LABELS[day])
|
||||
return time ? `${dayNames.join(', ')} · ${time}` : dayNames.join(', ')
|
||||
|
||||
@@ -1,8 +1,231 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { CalendarClock, Loader2, Plus, UserCheck, Users, Wallet, type LucideIcon } from 'lucide-react'
|
||||
import type { HomeSummaryDto, NextClassDto, PaymentDto, UpcomingPaymentDto } from '@gruperly/shared'
|
||||
import { Badge, Button } from '../components/ui'
|
||||
import { getHomeSummary } from '../lib/api'
|
||||
import {
|
||||
formatPrice,
|
||||
formatRelativeDateTime,
|
||||
formatSchedule,
|
||||
PAYMENT_STATUS_LABELS,
|
||||
} from '../lib/format'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
const PAYMENT_BADGE_VARIANT: Record<
|
||||
PaymentDto['status'],
|
||||
'success' | 'warning' | 'danger' | 'neutral'
|
||||
> = {
|
||||
PENDING: 'warning',
|
||||
OVERDUE: 'danger',
|
||||
PAID: 'success',
|
||||
CANCELLED: 'neutral',
|
||||
}
|
||||
|
||||
function NextClassHero({ nextClass }: { nextClass: NextClassDto }) {
|
||||
const navigate = useNavigate()
|
||||
const hasSchedule = (nextClass.days?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<article className="relative overflow-hidden rounded-xl border border-accent/30 bg-surface p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-foreground/50">
|
||||
{nextClass.isNow ? 'Clase en curso' : 'Próxima clase'}
|
||||
</p>
|
||||
<h2 className="mt-1 truncate text-xl font-bold text-primary">{nextClass.name}</h2>
|
||||
{hasSchedule ? (
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-foreground/70">
|
||||
<CalendarClock className="size-4 shrink-0 text-accent" />
|
||||
<span>{formatSchedule(nextClass.days ?? [], nextClass.time)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant={nextClass.isNow ? 'success' : 'neutral'}>
|
||||
{nextClass.isNow ? 'En curso ahora' : formatRelativeDateTime(nextClass.occurrenceAt)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end border-t border-border pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void navigate({ to: `/groups/${nextClass.groupId}` })}
|
||||
>
|
||||
Ver grupo
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function UpcomingPaymentRow({ payment }: { payment: UpcomingPaymentDto }) {
|
||||
return (
|
||||
<li className="flex items-center justify-between gap-3 rounded-xl border border-border bg-surface px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-primary">{payment.attendeeName}</p>
|
||||
<p className="mt-0.5 truncate text-xs text-foreground/60">
|
||||
{payment.groupName} · {formatRelativeDateTime(payment.dueDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-sm font-semibold text-primary">{formatPrice(payment.amount)}</span>
|
||||
<Badge variant={PAYMENT_BADGE_VARIANT[payment.status]}>
|
||||
{PAYMENT_STATUS_LABELS[payment.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryStats({ summary }: { summary: HomeSummaryDto }) {
|
||||
const navigate = useNavigate()
|
||||
const stats: {
|
||||
label: string
|
||||
value: string
|
||||
sub?: string
|
||||
icon: LucideIcon
|
||||
to: '/groups' | '/payments'
|
||||
}[] = [
|
||||
{
|
||||
label: 'Grupos activos',
|
||||
value: String(summary.stats.groups),
|
||||
icon: Users,
|
||||
to: '/groups',
|
||||
},
|
||||
{
|
||||
label: 'Cobros pendientes',
|
||||
value: summary.stats.pendingAmount > 0 ? formatPrice(summary.stats.pendingAmount) : '0',
|
||||
sub: `${summary.stats.pendingPayments} por cobrar`,
|
||||
icon: Wallet,
|
||||
to: '/payments',
|
||||
},
|
||||
{
|
||||
label: 'Asistentes',
|
||||
value: String(summary.stats.attendees),
|
||||
icon: UserCheck,
|
||||
to: '/groups',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon
|
||||
return (
|
||||
<button
|
||||
key={stat.label}
|
||||
type="button"
|
||||
onClick={() => void navigate({ to: stat.to })}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-xl border border-border bg-surface p-4 text-left transition-all',
|
||||
'hover:border-accent/40',
|
||||
)}
|
||||
>
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent">
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xl font-bold leading-tight text-primary">{stat.value}</span>
|
||||
<span className="block truncate text-xs text-foreground/60">
|
||||
{stat.sub ?? stat.label}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeView() {
|
||||
const navigate = useNavigate()
|
||||
const summaryQuery = useQuery({
|
||||
queryKey: ['home-summary'],
|
||||
queryFn: getHomeSummary,
|
||||
})
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 className="text-2xl font-bold text-primary">Inicio</h1>
|
||||
<p className="mt-2 text-sm text-foreground/60">Bienvenido a Gruperly.</p>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Inicio</h1>
|
||||
<p className="mt-2 text-sm text-foreground/60">Tu actividad de cobros de un vistazo.</p>
|
||||
</div>
|
||||
|
||||
{summaryQuery.isPending ? (
|
||||
<div className="mt-8 flex items-center justify-center py-16">
|
||||
<Loader2 className="size-6 animate-spin text-foreground/40" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{summaryQuery.isError ? (
|
||||
<div className="mt-8 space-y-3 rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
|
||||
<p>No pudimos cargar tu resumen.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void summaryQuery.refetch()}>
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{summaryQuery.isSuccess && summaryQuery.data.stats.groups === 0 ? (
|
||||
<div className="mt-8 rounded-xl border border-dashed border-border bg-surface px-4 py-12 text-center">
|
||||
<p className="font-medium text-primary">Todavía no tenés grupos</p>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Crea tu primer grupo para empezar a cobrar.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-5"
|
||||
onClick={() => void navigate({ to: '/groups/new' })}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Crear tu primer grupo
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{summaryQuery.isSuccess && summaryQuery.data.stats.groups > 0 ? (
|
||||
<div className="mt-6 space-y-6">
|
||||
{summaryQuery.data.nextClass ? (
|
||||
<NextClassHero nextClass={summaryQuery.data.nextClass} />
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border bg-surface px-4 py-8 text-center">
|
||||
<p className="text-sm font-medium text-primary">Sin clases programadas</p>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Agregá días y horario a tus grupos para ver tu próxima clase acá.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SummaryStats summary={summaryQuery.data} />
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-bold text-primary">Próximos cobros</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void navigate({ to: '/payments' })}
|
||||
>
|
||||
Ver todos
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{summaryQuery.data.upcomingPayments.length > 0 ? (
|
||||
<ul className="mt-3 space-y-3">
|
||||
{summaryQuery.data.upcomingPayments.map((payment) => (
|
||||
<UpcomingPaymentRow key={payment.id} payment={payment} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-3 rounded-xl border border-dashed border-border bg-surface px-4 py-6 text-center text-sm text-foreground/60">
|
||||
Sin cobros pendientes por ahora.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export * from './schemas/attendees.js';
|
||||
export * from './schemas/enums.js';
|
||||
export * from './schemas/groups.js';
|
||||
export * from './schemas/health-check.js';
|
||||
export * from './schemas/home.js';
|
||||
export * from './schemas/onboarding.js';
|
||||
export * from './schemas/pagination.js';
|
||||
export * from './schemas/payments.js';
|
||||
|
||||
62
packages/shared/src/schemas/home.ts
Normal file
62
packages/shared/src/schemas/home.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { z } from 'zod';
|
||||
import { weekdaySchema } from './enums.js';
|
||||
import { paymentStatusSchema } from './payments.js';
|
||||
|
||||
const isoDateTimeSchema = z.string().datetime();
|
||||
|
||||
const timeZoneSchema = z
|
||||
.string()
|
||||
.refine((tz) => {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: tz });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 'Zona horaria IANA inválida');
|
||||
|
||||
export const HomeStatsSchema = z.object({
|
||||
groups: z.number().int().nonnegative(),
|
||||
attendees: z.number().int().nonnegative(),
|
||||
pendingPayments: z.number().int().nonnegative(),
|
||||
pendingAmount: z.number().nonnegative(),
|
||||
});
|
||||
export type HomeStats = z.output<typeof HomeStatsSchema>;
|
||||
|
||||
export const NextClassSchema = z.object({
|
||||
groupId: z.string(),
|
||||
name: z.string(),
|
||||
days: z.array(weekdaySchema).nullable(),
|
||||
time: z.string().nullable(),
|
||||
occurrenceAt: isoDateTimeSchema,
|
||||
isNow: z.boolean(),
|
||||
});
|
||||
export type NextClass = z.output<typeof NextClassSchema>;
|
||||
|
||||
export const UpcomingPaymentSchema = z.object({
|
||||
id: z.string(),
|
||||
amount: z.coerce.number().positive(),
|
||||
currency: z.string(),
|
||||
dueDate: isoDateTimeSchema,
|
||||
status: paymentStatusSchema,
|
||||
groupName: z.string(),
|
||||
attendeeName: z.string(),
|
||||
});
|
||||
export type UpcomingPayment = z.output<typeof UpcomingPaymentSchema>;
|
||||
|
||||
export const HomeSummarySchema = z.object({
|
||||
stats: HomeStatsSchema,
|
||||
nextClass: NextClassSchema.nullable(),
|
||||
upcomingPayments: z.array(UpcomingPaymentSchema),
|
||||
});
|
||||
export type HomeSummary = z.output<typeof HomeSummarySchema>;
|
||||
|
||||
export type HomeSummaryDto = HomeSummary;
|
||||
export type HomeStatsDto = HomeStats;
|
||||
export type NextClassDto = NextClass;
|
||||
export type UpcomingPaymentDto = UpcomingPayment;
|
||||
|
||||
export const HomeQuerySchema = z.object({
|
||||
timeZone: timeZoneSchema.optional(),
|
||||
});
|
||||
export type HomeQuery = z.output<typeof HomeQuerySchema>;
|
||||
Reference in New Issue
Block a user