From f1e0fc5aae4dfee885ac5d3dbb42ba419196b6a4 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Wed, 23 Sep 2026 10:35:24 -0300 Subject: [PATCH] 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. --- apps/backend/src/app.ts | 2 + .../home/features/get-summary/route.ts | 23 ++ .../home/features/get-summary/use-case.ts | 134 +++++++++ apps/backend/src/modules/home/index.ts | 1 + .../backend/src/modules/home/lib/scheduler.ts | 146 +++++++++ apps/backend/src/modules/home/routes.ts | 8 + apps/backend/test/home-scheduler.test.ts | 112 +++++++ apps/backend/test/home.test.ts | 277 ++++++++++++++++++ apps/web/src/lib/api.ts | 13 + apps/web/src/lib/format.ts | 31 +- apps/web/src/routes/home.tsx | 233 ++++++++++++++- packages/shared/src/index.ts | 1 + packages/shared/src/schemas/home.ts | 62 ++++ 13 files changed, 1037 insertions(+), 6 deletions(-) create mode 100644 apps/backend/src/modules/home/features/get-summary/route.ts create mode 100644 apps/backend/src/modules/home/features/get-summary/use-case.ts create mode 100644 apps/backend/src/modules/home/index.ts create mode 100644 apps/backend/src/modules/home/lib/scheduler.ts create mode 100644 apps/backend/src/modules/home/routes.ts create mode 100644 apps/backend/test/home-scheduler.test.ts create mode 100644 apps/backend/test/home.test.ts create mode 100644 packages/shared/src/schemas/home.ts diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index e64aa0e..7d511fb 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -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); diff --git a/apps/backend/src/modules/home/features/get-summary/route.ts b/apps/backend/src/modules/home/features/get-summary/route.ts new file mode 100644 index 0000000..d5bd181 --- /dev/null +++ b/apps/backend/src/modules/home/features/get-summary/route.ts @@ -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; \ No newline at end of file diff --git a/apps/backend/src/modules/home/features/get-summary/use-case.ts b/apps/backend/src/modules/home/features/get-summary/use-case.ts new file mode 100644 index 0000000..84297be --- /dev/null +++ b/apps/backend/src/modules/home/features/get-summary/use-case.ts @@ -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; + 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> { + 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; + } +} \ No newline at end of file diff --git a/apps/backend/src/modules/home/index.ts b/apps/backend/src/modules/home/index.ts new file mode 100644 index 0000000..8d35a7f --- /dev/null +++ b/apps/backend/src/modules/home/index.ts @@ -0,0 +1 @@ +export { default as homeRoutes } from './routes'; \ No newline at end of file diff --git a/apps/backend/src/modules/home/lib/scheduler.ts b/apps/backend/src/modules/home/lib/scheduler.ts new file mode 100644 index 0000000..b92aed7 --- /dev/null +++ b/apps/backend/src/modules/home/lib/scheduler.ts @@ -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 = { + 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>((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>((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'); +} \ No newline at end of file diff --git a/apps/backend/src/modules/home/routes.ts b/apps/backend/src/modules/home/routes.ts new file mode 100644 index 0000000..b2273f5 --- /dev/null +++ b/apps/backend/src/modules/home/routes.ts @@ -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; \ No newline at end of file diff --git a/apps/backend/test/home-scheduler.test.ts b/apps/backend/test/home-scheduler.test.ts new file mode 100644 index 0000000..dba01c2 --- /dev/null +++ b/apps/backend/test/home-scheduler.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/apps/backend/test/home.test.ts b/apps/backend/test/home.test.ts new file mode 100644 index 0000000..a8a427b --- /dev/null +++ b/apps/backend/test/home.test.ts @@ -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) => cb(db)); + execute = mock(async (cb: (tx: unknown) => Promise) => 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); + }); +}); \ No newline at end of file diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 14601f5..0de4b2c 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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('/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(`/api/v1/home${params}`) +} + export const getGroup = (groupId: string) => apiFetch(`/api/v1/groups/${groupId}`) diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index aa961b3..286bb04 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -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 = { MONDAY: 'Lunes', @@ -15,6 +15,35 @@ export const BILLING_LABELS: Record = { PER_CLASS: 'Por clase', } +export const PAYMENT_STATUS_LABELS: Record = { + 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(', ') diff --git a/apps/web/src/routes/home.tsx b/apps/web/src/routes/home.tsx index c76192e..759041e 100644 --- a/apps/web/src/routes/home.tsx +++ b/apps/web/src/routes/home.tsx @@ -1,8 +1,231 @@ -export function HomeView() { +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 ( -
-

Inicio

-

Bienvenido a Gruperly.

-
+
+
+
+

+ {nextClass.isNow ? 'Clase en curso' : 'Próxima clase'} +

+

{nextClass.name}

+ {hasSchedule ? ( +
+ + {formatSchedule(nextClass.days ?? [], nextClass.time)} +
+ ) : null} +
+
+ + {nextClass.isNow ? 'En curso ahora' : formatRelativeDateTime(nextClass.occurrenceAt)} + +
+
+
+ +
+
) } + +function UpcomingPaymentRow({ payment }: { payment: UpcomingPaymentDto }) { + return ( +
  • +
    +

    {payment.attendeeName}

    +

    + {payment.groupName} · {formatRelativeDateTime(payment.dueDate)} +

    +
    +
    + {formatPrice(payment.amount)} + + {PAYMENT_STATUS_LABELS[payment.status]} + +
    +
  • + ) +} + +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 ( +
    + {stats.map((stat) => { + const Icon = stat.icon + return ( + + ) + })} +
    + ) +} + +export function HomeView() { + const navigate = useNavigate() + const summaryQuery = useQuery({ + queryKey: ['home-summary'], + queryFn: getHomeSummary, + }) + + return ( +
    +
    +

    Inicio

    +

    Tu actividad de cobros de un vistazo.

    +
    + + {summaryQuery.isPending ? ( +
    + +
    + ) : null} + + {summaryQuery.isError ? ( +
    +

    No pudimos cargar tu resumen.

    + +
    + ) : null} + + {summaryQuery.isSuccess && summaryQuery.data.stats.groups === 0 ? ( +
    +

    Todavía no tenés grupos

    +

    + Crea tu primer grupo para empezar a cobrar. +

    + +
    + ) : null} + + {summaryQuery.isSuccess && summaryQuery.data.stats.groups > 0 ? ( +
    + {summaryQuery.data.nextClass ? ( + + ) : ( +
    +

    Sin clases programadas

    +

    + Agregá días y horario a tus grupos para ver tu próxima clase acá. +

    +
    + )} + + + +
    +
    +

    Próximos cobros

    + +
    + + {summaryQuery.data.upcomingPayments.length > 0 ? ( +
      + {summaryQuery.data.upcomingPayments.map((payment) => ( + + ))} +
    + ) : ( +

    + Sin cobros pendientes por ahora. +

    + )} +
    +
    + ) : null} +
    + ) +} \ No newline at end of file diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index caf13a3..224ef49 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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'; diff --git a/packages/shared/src/schemas/home.ts b/packages/shared/src/schemas/home.ts new file mode 100644 index 0000000..dcada4f --- /dev/null +++ b/packages/shared/src/schemas/home.ts @@ -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; + +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; + +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; + +export const HomeSummarySchema = z.object({ + stats: HomeStatsSchema, + nextClass: NextClassSchema.nullable(), + upcomingPayments: z.array(UpcomingPaymentSchema), +}); +export type HomeSummary = z.output; + +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; \ No newline at end of file