Prompt file imported from devmauriciopineda/careme (
.github/prompts/plan-uc002RegisterMeasurement.prompt.md). Copyright stays with the author.
Plan: Implementar UC-002 (registrar medición del día)
Añadir un formulario de registro en la pantalla de seguimiento que dé de alta la medición del día, con reemplazo si el día ya tenía registro. Backend: nuevo endpoint POST con validación y upsert por fecha (la tabla ya tiene UNIQUE(date) y CHECK, sin migración nueva). Frontend: Client Component con inputs controlados + Zod que invoca una Server Action; al revalidar la ruta, la tendencia y la tabla se actualizan solas. El formulario queda siempre visible, incluso sin mediciones (UC-002 no exige registros previos).
Decisiones confirmadas con el usuario
- Mutación vía Server Action + revalidatePath (formulario = Client Component).
- Formulario con inputs controlados + Zod (sin dependencias nuevas).
- Pruebas: backend (JUnit/MockMvc/Testcontainers) + frontend (Vitest/RTL). Sin e2e Playwright.
Contexto clave
- Backend: DDD ligero, layered controller→service→repository. Dominio
Measurement(record, id UUID no nulo, valores positivos),MeasurementEntityJPA (id generado, date unique),MeasurementResponseDTO,ApiResponse<T>envelope éxito,ErrorResponseenvelope error,ApiExceptionHandlersolo maneja Exception→500. - Esquema
measurementsya soporta el write (UNIQUE date, CHECK > 0, NUMERIC(5,2)). No requiere migración nueva. - Frontend: dashboard es Server Component (
MeasurementsDashboard.tsx) que hace fetch server-side víameasurementService.getMeasurements()(cache no-store) y valida con Zod (schema.ts). UI shadcn: solo button/card/chart/separator/table (faltan input/label).API_BASE_URLes server-only. - CORS backend solo permite GET, pero el write va por Server Action (server-side), así que no cambia.
Pasos
Backend
dto/MeasurementRequest.java(nuevo): record con Bean Validation@NotNull @PastOrPresent LocalDate date@NotNull @DecimalMin(value="0.0", inclusive=false) @DecimalMax("500.0") @Digits(integer=3, fraction=1) BigDecimal weightKg@NotNull @DecimalMin(value="0.0", inclusive=false) @DecimalMax("400.0") @Digits(integer=3, fraction=1) BigDecimal waistCm
entity/MeasurementEntity.java: añadirupdateValues(BigDecimal weightKg, BigDecimal waistCm)(fila gestionada, sin tocar la fecha).repository/MeasurementJpaDao.java: añadirOptional<MeasurementEntity> findByDate(LocalDate date).repository/MeasurementRepository.java(contrato): añadirOptional<Measurement> findByDate(LocalDate date)Measurement create(LocalDate date, BigDecimal weightKg, BigDecimal waistCm)Measurement update(UUID id, BigDecimal weightKg, BigDecimal waistCm)
repository/MeasurementJpaRepository.java: implementar los tres (create = save entidad nueva; update = findById + updateValues + save; findByDate = mapear a dominio).create/updatetransaccionales de escritura.service/MeasurementService.java:register(MeasurementRequest)— aplica la regla "una medición por día": sifindByDatepresente →update(id, ...); si no →create(...); devuelveMeasurementResponse. Loguear.controller/MeasurementController.java:@PostMappingcon@Valid @RequestBody MeasurementRequest, responde 201 Created conApiResponse.ok(saved).exception/ApiExceptionHandler.java: añadirMethodArgumentNotValidException→ 400, codeVALIDATION_ERROR,details= mensajes por campo.HttpMessageNotReadableException→ 400, codeINVALID_REQUEST(JSON/fecha/número mal formados).
Sin esto, los errores de validación caerían en el 500 genérico.
Frontend
src/components/ui/input.tsxylabel.tsx(nuevos): primitivos shadcn (víapnpm dlx shadcn@latest add input label).features/measurements/types.ts: añadirMeasurementInput{ date: string; weightKg: number; waistCm: number }.features/measurements/lib/schema.ts: añadirmeasurementInputSchema(números: positivo, ≤500/≤400, un decimal, fecha ISO no futura).measurementFormSchema(strings del formulario → valida requerido/numérico/positivo/un decimal/máximo/no futura y transforma aMeasurementInput, mensajes en español).measurementCreatedResponseSchema(envelope condata= una medición).
features/measurements/lib/metrics.ts: añadirtodayIsoDate()(día local, sin shift UTC) yisFutureIsoDate(iso, today).features/measurements/lib/strings.ts: añadirform(título, etiquetas, botón, enviando, éxito,saveFailedcon reintento) y mensajes de validación (requerido, no numérico, positivo, un decimal, máximo, fecha futura).src/services/measurementService.ts: añadircreateMeasurement(input: MeasurementInput): Promise<Measurement>— POST JSON a/api/v1/measurements, valida conmeasurementCreatedResponseSchema, devuelve la medición; lanza si el status no es ok.features/measurements/actions.ts(nuevo,"use server"):registerMeasurement(input)— revalida conmeasurementInputSchema, llamameasurementService.createMeasurement,revalidatePath("/"), devuelve resultado discriminado{ ok: true } | { ok: false; errorCode }serializable.features/measurements/components/MeasurementForm.tsx(nuevo,"use client"): inputs controlados fecha (default hoy,max=hoy)/peso/cintura; valida conmeasurementFormSchema; errores por campo conaria-invalid/aria-describedbyy anunciadoraria-live(no solo color); deshabilita botón mientras pending; en éxito limpia campos numéricos y muestra confirmación; en fallo (E4) conserva lo digitado y ofrece reintentar.src/app/page.tsx: renderizar<MeasurementForm />encima de<MeasurementsDashboard />(form visible también con 0 mediciones).
Archivos relevantes
backend/.../controller/MeasurementController.java,service/MeasurementService.java,repository/MeasurementRepository.java+MeasurementJpaRepository.java,entity/MeasurementEntity.java,dto/MeasurementRequest.java(nuevo),exception/ApiExceptionHandler.java— patrón existenteMeasurementResponse/ApiResponse/ErrorResponsereutilizable.frontend/src/app/page.tsx,features/measurements/components/MeasurementsDashboard.tsx(se mantiene),lib/schema.ts,lib/metrics.ts,lib/strings.ts,services/measurementService.ts,components/MeasurementForm.tsx(nuevo),features/measurements/actions.ts(nuevo).backend/src/main/resources/db/migration/V1__create_measurements_table.sql— sin cambios.
Pruebas
Backend:
MeasurementControllerTest: POST 201 envelope/eco; POST inválido → 400 VALIDATION_ERROR; fecha futura → 400; JSON mal formado → 400.MeasurementServiceTest:registercrea si no existe la fecha; reemplaza si existe (sin duplicar); mapea campos.MeasurementJpaRepositoryTest:findByDatepresente/ausente;createinserta;updatemodifica la misma fila (count sin cambios).
Frontend:
schema.test.ts: casos demeasurementFormSchema/measurementInputSchema(válido, faltante, no numérico, no positivo, >500/400, dos decimales, fecha futura, coma decimal).measurementService.test.ts:createMeasurement(éxito, error de status, payload malformado, método/body/cache).MeasurementForm.test.tsx: etiquetas accesibles; submit inválido no llama la acción y muestra errores; submit válido llama la acción; fallo conserva valores y muestra reintento; botón deshabilitado mientras pending.
Verificación
- Backend:
.\mvnw.cmd verify(JUnit + JaCoCo ≥90% branch/line; Testcontainers requiere Docker). - Frontend:
pnpm test,pnpm typecheck,pnpm lint. - Manual con
docker-compose up: registrar medición → tendencia y tabla se actualizan sin recargar; re-registrar el mismo día → reemplaza (no duplica); valores inválidos → mensajes; backend caído → E4 conserva datos y permite reintentar.
Documentación
- Actualizar
backend/README.md(contrato POST),frontend/README.md(formulario) yREADME.mdgeneral (ya no es read-only). - Marcar UC-002
Estado: Implementado.
Fuera de alcance
- Editar/borrar medición como acción independiente; multiusuario/auth; notificaciones/objetivos; e2e Playwright; nueva ruta dedicada.
Consideraciones abiertas
- Zona horaria de "hoy":
@PastOrPresentusa la zona del servidor; el default del formulario usa la zona del navegador. Cerca de medianoche podrían discrepar. Opción A: dejarlo como MVP (recomendado) / Opción B: fijar zona explícita configurable en backend. - Carrera de doble envío real: el
UNIQUE(date)es la última barrera; el botón deshabilitado cubre el caso UI. Opción A: dejarlo (recomendado) / Opción B: capturarDataIntegrityViolationExceptiony reintentar como update.