diff --git a/backend/alembic/versions/112_align_body_areas_with_frontend.py b/backend/alembic/versions/112_align_body_areas_with_frontend.py new file mode 100644 index 0000000..6d903e5 --- /dev/null +++ b/backend/alembic/versions/112_align_body_areas_with_frontend.py @@ -0,0 +1,208 @@ +"""align body area catalog with frontend + +Revision ID: 112_align_body_areas_with_frontend +Revises: 111_create_journey_events +Create Date: 2026-06-09 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +revision: str = "112_align_body_areas" +down_revision: str | None = "111_create_journey_events" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +BODY_AREAS = ( + ("d7246d41-427a-4acf-b7f1-19c87e045a23", "face", "Face", "center", "head", 50, 10, "front"), + ("e4718197-848b-488c-a9e6-29ce3c978526", "neck", "Pescoço", "center", "head", 50, 17, "front"), + ( + "7a9d2743-a641-46f2-8206-a6bc967652e0", + "shoulders", + "Ombros", + "center", + "upper_limb", + 25, + 25, + "front", + ), + ( + "57d0040e-9cb5-4930-8d68-e5db16409026", + "arms", + "Braços", + "center", + "upper_limb", + 20, + 50, + "front", + ), + ( + "d0427a6e-14da-4f46-a2f5-6df4581e0b3b", + "hands", + "Mãos", + "center", + "upper_limb", + 15, + 75, + "front", + ), + ( + "64d0af93-f2d5-484f-a1a6-53d8f3778cad", + "abdomen", + "Abdômen", + "center", + "trunk", + 50, + 35, + "front", + ), + ("93d7f00b-cde9-4e2d-a84d-f6618e3dafaf", "hip", "Quadril", "center", "trunk", 50, 50, "front"), + ( + "2c242eb6-0c2f-43ee-a625-7aed0593c953", + "legs", + "Pernas", + "center", + "lower_limb", + 35, + 65, + "front", + ), + ( + "3ae541eb-62b2-4298-aa6f-b95a785a35a9", + "knees", + "Joelhos", + "center", + "lower_limb", + 35, + 80, + "front", + ), + ( + "b0d125a5-366f-43b4-b17c-d0b61743ed69", + "feet", + "Pés", + "center", + "lower_limb", + 35, + 95, + "front", + ), + ( + "5c19647e-df64-4bc1-893a-c8a077612631", + "scalp", + "Couro cabeludo", + "center", + "head", + 50, + 8, + "back", + ), + ("49201184-5df7-4f50-a3de-d28258586138", "nape", "Nuca", "center", "head", 50, 17, "back"), + ("04947b01-d28b-4db1-993a-3bb18b68ecac", "back", "Costas", "center", "trunk", 50, 35, "back"), + ( + "e224e751-dff5-4215-b165-d9a2d03c4431", + "buttocks", + "Glúteos", + "center", + "trunk", + 50, + 52, + "back", + ), + ( + "43265efe-892f-45f6-afb4-639373512cfb", + "posterior_thighs", + "Posterior das coxas", + "center", + "lower_limb", + 35, + 65, + "back", + ), + ( + "3d0353f1-5c81-4c17-9d22-b900f67d141e", + "calves", + "Panturrilhas", + "center", + "lower_limb", + 35, + 85, + "back", + ), +) + + +def upgrade() -> None: + body_view_enum = sa.Enum("front", "back", name="body_view_enum") + body_view_enum.create(op.get_bind(), checkfirst=True) + + op.add_column("body_areas", sa.Column("x", sa.SmallInteger(), nullable=True)) + op.add_column("body_areas", sa.Column("y", sa.SmallInteger(), nullable=True)) + op.add_column("body_areas", sa.Column("view", body_view_enum, nullable=True)) + op.add_column( + "body_areas", + sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False), + ) + + op.execute("UPDATE body_areas SET x = 50, y = 50, view = 'front', is_active = false") + op.alter_column("body_areas", "x", nullable=False) + op.alter_column("body_areas", "y", nullable=False) + op.alter_column("body_areas", "view", nullable=False) + op.create_check_constraint("body_areas_x_range", "body_areas", "x >= 0 AND x <= 100") + op.create_check_constraint("body_areas_y_range", "body_areas", "y >= 0 AND y <= 100") + + body_areas = sa.table( + "body_areas", + sa.column("id", UUID(as_uuid=True)), + sa.column("code", sa.Text()), + sa.column("label", sa.Text()), + sa.column("side", sa.Enum(name="body_side_enum")), + sa.column("system_part", sa.Enum(name="body_system_part_enum")), + sa.column("x", sa.SmallInteger()), + sa.column("y", sa.SmallInteger()), + sa.column("view", sa.Enum(name="body_view_enum")), + sa.column("is_active", sa.Boolean()), + ) + op.bulk_insert( + body_areas, + [ + { + "id": area_id, + "code": code, + "label": label, + "side": side, + "system_part": system_part, + "x": x, + "y": y, + "view": view, + "is_active": True, + } + for area_id, code, label, side, system_part, x, y, view in BODY_AREAS + if code != "abdomen" + ], + ) + op.execute( + """ + UPDATE body_areas + SET label = 'Abdômen', side = 'center', system_part = 'trunk', + x = 50, y = 35, view = 'front', is_active = true + WHERE code = 'abdomen' + """ + ) + + +def downgrade() -> None: + codes = ", ".join(f"'{area[1]}'" for area in BODY_AREAS if area[1] != "abdomen") + op.execute(f"DELETE FROM body_areas WHERE code IN ({codes})") + op.execute("UPDATE body_areas SET is_active = true WHERE code = 'abdomen'") + op.drop_constraint("body_areas_y_range", "body_areas", type_="check") + op.drop_constraint("body_areas_x_range", "body_areas", type_="check") + op.drop_column("body_areas", "is_active") + op.drop_column("body_areas", "view") + op.drop_column("body_areas", "y") + op.drop_column("body_areas", "x") + sa.Enum(name="body_view_enum").drop(op.get_bind(), checkfirst=True) diff --git a/backend/alembic/versions/aeb509f804c0_create_daily_medication_progress_table.py b/backend/alembic/versions/aeb509f804c0_create_daily_medication_progress_table.py new file mode 100644 index 0000000..662ed47 --- /dev/null +++ b/backend/alembic/versions/aeb509f804c0_create_daily_medication_progress_table.py @@ -0,0 +1,78 @@ +"""create daily medication progress table + +Revision ID: aeb509f804c0 +Revises: 108_patient_health_appointments +Create Date: 2026-06-08 23:56:27.762692 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "aeb509f804c0" +down_revision: str | None = "112_align_body_areas" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "daily_medication_progress", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("patient_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("progress_date", sa.Date(), nullable=False), + sa.Column("expected_count", sa.Integer(), nullable=False), + sa.Column("taken_count", sa.Integer(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["patient_id"], + ["patient_profiles.id"], + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "patient_id", + "progress_date", + name="uq_daily_medication_progress_patient_date", + ), + ) + + op.create_index( + "ix_daily_medication_progress_patient_id", + "daily_medication_progress", + ["patient_id"], + unique=False, + ) + op.create_index( + "ix_daily_medication_progress_progress_date", + "daily_medication_progress", + ["progress_date"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_daily_medication_progress_progress_date", + table_name="daily_medication_progress", + ) + op.drop_index( + "ix_daily_medication_progress_patient_id", + table_name="daily_medication_progress", + ) + op.drop_table("daily_medication_progress") diff --git a/backend/bruno/ROUTES.md b/backend/bruno/ROUTES.md index 08de930..a702ace 100644 --- a/backend/bruno/ROUTES.md +++ b/backend/bruno/ROUTES.md @@ -52,6 +52,7 @@ Contrato HTTP da API v1. Fonte: `docs/milestones/M*.md`. | `PATCH` | `/v1/patients/me` | 20/min | patient | `patient/update_profile.bru` ✅ | | `GET` | `/v1/patients` | 100/min | professional, admin | `patient/list_patients.bru` | | `GET` | `/v1/patients/{id}` | 100/min | professional, admin | `patient/get_patient.bru` | +| `GET` | `/v1/patients/me/journey` | 100/min | patient | `patient/get_journey.bru` | --- diff --git a/backend/bruno/patient/get_journey.bru b/backend/bruno/patient/get_journey.bru new file mode 100644 index 0000000..b12fafc --- /dev/null +++ b/backend/bruno/patient/get_journey.bru @@ -0,0 +1,19 @@ +meta { + name: Get Journey + type: http + seq: 10 +} + +get { + url: {{baseUrl}}/v1/patients/me/journey + auth: bearer +} + +assert { + res.status: eq 200 + res.body.summary: isDefined + res.body.summary.classification: isDefined + res.body.summary.treatment_duration_months: isDefined + res.body.summary.progress_percent: isDefined + res.body.months: isDefined +} \ No newline at end of file diff --git a/backend/src/pequi/main.py b/backend/src/pequi/main.py index 7f40ccc..16b2f17 100644 --- a/backend/src/pequi/main.py +++ b/backend/src/pequi/main.py @@ -83,11 +83,6 @@ async def health_check() -> JSONResponse: from pequi.routers import treatment_v2 as treatment_v2_router app.include_router(patient_router.router, prefix="/v1/patients", tags=["patients"]) - app.include_router( - journey_router.router, - prefix="/v1/patients/me/journey", - tags=["journey"], - ) app.include_router(journey_router.router, prefix="/v2/journey", tags=["journey"]) app.include_router(account_router.router, prefix="/v1/account", tags=["account"]) app.include_router(auth_router.router, prefix="/v1/auth", tags=["auth"]) diff --git a/backend/src/pequi/models/__init__.py b/backend/src/pequi/models/__init__.py index 3818323..521b45b 100644 --- a/backend/src/pequi/models/__init__.py +++ b/backend/src/pequi/models/__init__.py @@ -10,6 +10,7 @@ CommunityPost, ) from pequi.models.consent import Consent +from pequi.models.daily_medication_progress import DailyMedicationProgress from pequi.models.data_deletion import DataDeletionRequest from pequi.models.dose_log import AdherenceSnapshot, DoseLog from pequi.models.health_appointment import PatientHealthAppointment @@ -50,4 +51,5 @@ "Treatment", "User", "WeeklySymptomSummary", + "DailyMedicationProgress", ] diff --git a/backend/src/pequi/models/body_map.py b/backend/src/pequi/models/body_map.py index 7555238..1709d14 100644 --- a/backend/src/pequi/models/body_map.py +++ b/backend/src/pequi/models/body_map.py @@ -2,6 +2,7 @@ from enum import StrEnum from sqlalchemy import ( + Boolean, CheckConstraint, Column, DateTime, @@ -33,6 +34,11 @@ class BodySystemPart(StrEnum): lower_limb = "lower_limb" +class BodyView(StrEnum): + front = "front" + back = "back" + + class BodyFindingType(StrEnum): lesion = "lesion" hypoesthesia = "hypoesthesia" @@ -44,6 +50,8 @@ class BodyFindingType(StrEnum): class BodyArea(Base): __tablename__ = "body_areas" __table_args__ = ( + CheckConstraint("x >= 0 AND x <= 100", name="body_areas_x_range"), + CheckConstraint("y >= 0 AND y <= 100", name="body_areas_y_range"), Index("ix_body_areas_system_part", "system_part"), Index("ix_body_areas_label", "label"), ) @@ -59,6 +67,10 @@ class BodyArea(Base): Enum(BodySystemPart, name="body_system_part_enum"), nullable=False, ) + x = Column(SmallInteger, nullable=False, default=50) + y = Column(SmallInteger, nullable=False, default=50) + view = Column(Enum(BodyView, name="body_view_enum"), nullable=False, default=BodyView.front) + is_active = Column(Boolean, nullable=False, default=True, server_default=text("true")) class BodyMapEntry(Base): diff --git a/backend/src/pequi/models/daily_medication_progress.py b/backend/src/pequi/models/daily_medication_progress.py new file mode 100644 index 0000000..6da7f9c --- /dev/null +++ b/backend/src/pequi/models/daily_medication_progress.py @@ -0,0 +1,41 @@ +# pequi/models/daily_medication_progress.py +import uuid + +from sqlalchemy import Column, Date, DateTime, ForeignKey, Integer, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func + +from pequi.database import Base + + +class DailyMedicationProgress(Base): + __tablename__ = "daily_medication_progress" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + patient_id = Column( + UUID(as_uuid=True), + ForeignKey("patient_profiles.id", ondelete="RESTRICT"), + nullable=False, + ) + progress_date = Column(Date, nullable=False) + expected_count = Column(Integer, nullable=False) + taken_count = Column(Integer, nullable=False) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + __table_args__ = ( + UniqueConstraint( + "patient_id", + "progress_date", + name="uq_daily_medication_progress_patient_date", + ), + ) diff --git a/backend/src/pequi/repositories/body_map_repo.py b/backend/src/pequi/repositories/body_map_repo.py index bf637cf..864add5 100644 --- a/backend/src/pequi/repositories/body_map_repo.py +++ b/backend/src/pequi/repositories/body_map_repo.py @@ -13,14 +13,18 @@ def __init__(self, session: AsyncSession) -> None: self._session = session async def list_body_areas(self) -> list[BodyArea]: - stmt = select(BodyArea).order_by(BodyArea.system_part, BodyArea.label) + stmt = ( + select(BodyArea) + .where(BodyArea.is_active.is_(True)) + .order_by(BodyArea.system_part, BodyArea.label) + ) result = await self._session.execute(stmt) return list(result.scalars().all()) async def get_body_areas_by_ids(self, ids: Sequence[UUID]) -> list[BodyArea]: if not ids: return [] - stmt = select(BodyArea).where(BodyArea.id.in_(ids)) + stmt = select(BodyArea).where(BodyArea.id.in_(ids), BodyArea.is_active.is_(True)) result = await self._session.execute(stmt) return list(result.scalars().all()) diff --git a/backend/src/pequi/repositories/checkin_repo.py b/backend/src/pequi/repositories/checkin_repo.py index 4c3392d..ae9d589 100644 --- a/backend/src/pequi/repositories/checkin_repo.py +++ b/backend/src/pequi/repositories/checkin_repo.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import selectinload from pequi.models.checkin import Checkin, CheckinMood, checkin_symptoms -from pequi.schemas.checkin import CheckinCreate +from pequi.schemas.checkin import CheckinCreate, CheckinResponse class CheckinRepository: @@ -77,6 +77,26 @@ async def list_by_patient( result = await self._session.execute(stmt) return list(result.scalars().all()), total + async def list_history_by_patient_id( + self, + patient_id: UUID, + limit: int = 500, + offset: int = 0, + ) -> list[CheckinResponse]: + from pequi.schemas.checkin import checkin_to_response + + stmt = ( + select(Checkin) + .options(selectinload(Checkin.symptoms)) + .where(Checkin.patient_id == patient_id) + .order_by(Checkin.checked_in_at.asc()) + .limit(limit) + .offset(offset) + ) + result = await self._session.execute(stmt) + rows = list(result.scalars().all()) + return [checkin_to_response(row) for row in rows] + async def get_recent_moods( self, patient_id: UUID, diff --git a/backend/src/pequi/repositories/daily_medication_progress_repo.py b/backend/src/pequi/repositories/daily_medication_progress_repo.py new file mode 100644 index 0000000..0d49d50 --- /dev/null +++ b/backend/src/pequi/repositories/daily_medication_progress_repo.py @@ -0,0 +1,59 @@ +from datetime import date +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.models.daily_medication_progress import DailyMedicationProgress + + +class DailyMedicationProgressRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_patient_and_date( + self, + patient_id: UUID, + progress_date: date, + ) -> DailyMedicationProgress | None: + stmt = select(DailyMedicationProgress).where( + DailyMedicationProgress.patient_id == patient_id, + DailyMedicationProgress.progress_date == progress_date, + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_patient_id(self, patient_id: UUID) -> list[DailyMedicationProgress]: + stmt = ( + select(DailyMedicationProgress) + .where(DailyMedicationProgress.patient_id == patient_id) + .order_by(DailyMedicationProgress.progress_date.asc()) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + + async def upsert( + self, + patient_id: UUID, + progress_date: date, + expected_count: int, + taken_count: int, + ) -> DailyMedicationProgress: + progress = await self.get_by_patient_and_date(patient_id, progress_date) + + if progress is None: + progress = DailyMedicationProgress( + patient_id=patient_id, + progress_date=progress_date, + expected_count=expected_count, + taken_count=taken_count, + ) + self._session.add(progress) + await self._session.flush() + return progress + + progress.expected_count = expected_count + progress.taken_count = taken_count + await self._session.flush() + await self._session.refresh(progress) + return progress diff --git a/backend/src/pequi/routers/calendar_router.py b/backend/src/pequi/routers/calendar_router.py new file mode 100644 index 0000000..e119db7 --- /dev/null +++ b/backend/src/pequi/routers/calendar_router.py @@ -0,0 +1,27 @@ +from datetime import date + +from auth import get_current_user +from fastapi import APIRouter, Depends +from models.user import User + +router = APIRouter() + + +@router.get("/summary") +async def get_month_summary(year: int, month: int, current_user: User = Depends(get_current_user)): + + return { + "2026-05-24": ["checkin", "appointment"], + "2026-05-25": ["checkin"], + } + + +@router.get("/day-details") +async def get_day_details(target_date: date, current_user: User = Depends(get_current_user)): + return { + "date": target_date, + "events": [ + {"type": "checkin", "title": "Check-in matinal", "time": "08:00"}, + {"type": "appointment", "title": "Consulta com Dr. Silva", "time": "14:30"}, + ], + } diff --git a/backend/src/pequi/routers/journey.py b/backend/src/pequi/routers/journey.py index 1d596f3..86883bb 100644 --- a/backend/src/pequi/routers/journey.py +++ b/backend/src/pequi/routers/journey.py @@ -11,7 +11,7 @@ from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.journey import JourneyResponse -from pequi.use_cases.get_patient_journey import GetPatientJourneyUseCase +from pequi.use_cases.get_treatment_journey import GetTreatmentJourneyUseCase router = APIRouter() @@ -23,7 +23,7 @@ async def get_journey( patient_user_id: UUID = Depends(get_current_patient), session: AsyncSession = Depends(get_db), ) -> JourneyResponse: - use_case = GetPatientJourneyUseCase( + use_case = GetTreatmentJourneyUseCase( PatientRepository(session), TreatmentRepository(session), DoseRepository(session), diff --git a/backend/src/pequi/routers/patient.py b/backend/src/pequi/routers/patient.py index adc761d..0de2bd7 100644 --- a/backend/src/pequi/routers/patient.py +++ b/backend/src/pequi/routers/patient.py @@ -1,3 +1,4 @@ +from datetime import date from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Request @@ -5,17 +6,27 @@ from pequi.core.dependencies import get_current_patient, get_db from pequi.core.rate_limit import limiter +from pequi.repositories.checkin_repo import CheckinRepository +from pequi.repositories.daily_medication_progress_repo import ( + DailyMedicationProgressRepository, +) from pequi.repositories.dose_repo import DoseRepository from pequi.repositories.health_appointment_repo import HealthAppointmentRepository from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.daily_medication_progress import ( + DailyMedicationProgressResponse, + DailyMedicationProgressUpsert, + DailyMedicationSummaryResponse, +) from pequi.schemas.health_appointment import ( HealthAppointmentCreate, HealthAppointmentResponse, HealthAppointmentUpdate, ) from pequi.schemas.patient import PatientProfileRead, PatientProfileUpdate +from pequi.schemas.patient_journey import PatientJourneyResponse from pequi.schemas.patient_personal import ( PatientPersonalRecordRead, PatientPersonalRecordSave, @@ -26,6 +37,10 @@ PatientTreatmentRecordSave, ) from pequi.schemas.treatment import TreatmentResponse +from pequi.use_cases.get_daily_medication_summary import ( + GetDailyMedicationSummaryUseCase, +) +from pequi.use_cases.get_patient_journey import GetPatientJourneyUseCase from pequi.use_cases.get_patient_profile import GetPatientProfileUseCase from pequi.use_cases.patient_health_appointment import ( CreatePatientHealthAppointmentUseCase, @@ -43,6 +58,9 @@ SavePatientTreatmentRecordUseCase, ) from pequi.use_cases.update_patient_profile import UpdatePatientProfileUseCase +from pequi.use_cases.upsert_daily_medication_progress import ( + UpsertDailyMedicationProgressUseCase, +) router = APIRouter() @@ -56,6 +74,20 @@ def _treatment_repos( ) +def _daily_medication_progress_repos( + session: AsyncSession, +) -> tuple[ + DailyMedicationProgressRepository, + PatientRepository, +]: + return ( + DailyMedicationProgressRepository(session), + PatientRepository(session), + TreatmentRepository(session), + HealthProfessionalRepository(session), # noqa: F821 + ) + + @router.get("/me", response_model=PatientProfileRead) async def get_my_profile( user_id: UUID = Depends(get_current_patient), @@ -217,3 +249,46 @@ async def update_my_appointment( JourneyEventRepository(session), ) return await use_case.execute(user_id, appointment_id, body) + + +@router.get("/me/journey", response_model=PatientJourneyResponse) +@limiter.limit("100/minute") +async def get_my_journey( + request: Request, + user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> PatientJourneyResponse: + use_case = GetPatientJourneyUseCase( + PatientRepository(session), + TreatmentRepository(session), + HealthAppointmentRepository(session), + CheckinRepository(session), + DailyMedicationProgressRepository(session), + ) + return await use_case.execute(user_id) + + +@router.get("/me/daily-medication-progress", response_model=DailyMedicationSummaryResponse) +@limiter.limit("100/minute") +async def get_my_daily_medication_progress( + request: Request, + progress_date: date, + user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> DailyMedicationSummaryResponse: + progress_repo, patient_repo = _daily_medication_progress_repos(session) + use_case = GetDailyMedicationSummaryUseCase(progress_repo, patient_repo) + return await use_case.execute(user_id, progress_date) + + +@router.put("/me/daily-medication-progress", response_model=DailyMedicationProgressResponse) +@limiter.limit("20/minute") +async def save_my_daily_medication_progress( + request: Request, + body: DailyMedicationProgressUpsert, + user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> DailyMedicationProgressResponse: + progress_repo, patient_repo = _daily_medication_progress_repos(session) + use_case = UpsertDailyMedicationProgressUseCase(progress_repo, patient_repo) + return await use_case.execute(user_id, body) diff --git a/backend/src/pequi/schemas/body_map.py b/backend/src/pequi/schemas/body_map.py index 603fd7c..326b435 100644 --- a/backend/src/pequi/schemas/body_map.py +++ b/backend/src/pequi/schemas/body_map.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator -from pequi.models.body_map import BodyFindingType, BodySide, BodySystemPart +from pequi.models.body_map import BodyFindingType, BodySide, BodySystemPart, BodyView class BodyAreaResponse(BaseModel): @@ -12,6 +12,9 @@ class BodyAreaResponse(BaseModel): label: str side: BodySide system_part: BodySystemPart + x: int = Field(ge=0, le=100) + y: int = Field(ge=0, le=100) + view: BodyView model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/pequi/schemas/daily_medication_progress.py b/backend/src/pequi/schemas/daily_medication_progress.py new file mode 100644 index 0000000..caeb7d3 --- /dev/null +++ b/backend/src/pequi/schemas/daily_medication_progress.py @@ -0,0 +1,61 @@ +# pequi/schemas/daily_medication_progress.py +from datetime import date, datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class DailyMedicationProgressUpsert(BaseModel): + """Payload para salvar o progresso diário de medicações.""" + + model_config = ConfigDict(extra="forbid") + + progress_date: date + expected_count: int = Field( + ..., + ge=0, + description="Quantidade total de doses/checkboxes esperados no dia", + ) + taken_count: int = Field( + ..., + ge=0, + description="Quantidade de doses/checkboxes marcados como tomados no dia", + ) + + @model_validator(mode="after") + def validate_counts(self) -> "DailyMedicationProgressUpsert": + if self.taken_count > self.expected_count: + raise ValueError("taken_count não pode ser maior que expected_count.") + return self + + +class DailyMedicationProgressResponse(BaseModel): + id: UUID + patient_id: UUID + progress_date: date + expected_count: int + taken_count: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class DailyMedicationSummaryResponse(BaseModel): + progress_date: date + expected_count: int + taken_count: int + remaining_count: int + completed: bool + + +def daily_medication_progress_to_response(progress) -> DailyMedicationProgressResponse: + return DailyMedicationProgressResponse( + id=progress.id, + patient_id=progress.patient_id, + progress_date=progress.progress_date, + expected_count=progress.expected_count, + taken_count=progress.taken_count, + created_at=progress.created_at, + updated_at=progress.updated_at, + ) diff --git a/backend/src/pequi/schemas/patient_journey.py b/backend/src/pequi/schemas/patient_journey.py new file mode 100644 index 0000000..a4a29f3 --- /dev/null +++ b/backend/src/pequi/schemas/patient_journey.py @@ -0,0 +1,63 @@ +from datetime import date, datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class JourneySummary(BaseModel): + patient_id: UUID + user_id: UUID + display_name: str | None = None + classification: str | None = None + diagnosis_date: date | None = None + treatment_start_date: date | None = None + estimated_end_date: date | None = None + treatment_status: str | None = None + treatment_duration_months: int + total_days: int + elapsed_days: int + remaining_days: int + progress_percent: int + current_month: int + + model_config = ConfigDict(from_attributes=True) + + +class JourneyMedicationSummary(BaseModel): + doses_taken: int = 0 + doses_expected: int = 0 + adherence_percent: int = 0 + + model_config = ConfigDict(from_attributes=True) + + +class JourneyEvent(BaseModel): + id: str + type: str + date: datetime | date + title: str + description: str + status: str = "neutral" + metadata: dict[str, Any] | None = None + + model_config = ConfigDict(from_attributes=True) + + +class JourneyMonth(BaseModel): + month_index: int + label: str + start_date: date + end_date: date + status: str + medication_summary: JourneyMedicationSummary + events: list[JourneyEvent] = Field(default_factory=list) + + model_config = ConfigDict(from_attributes=True) + + +class PatientJourneyResponse(BaseModel): + summary: JourneySummary + months: list[JourneyMonth] + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/pequi/use_cases/get_daily_medication_summary.py b/backend/src/pequi/use_cases/get_daily_medication_summary.py new file mode 100644 index 0000000..9f0139f --- /dev/null +++ b/backend/src/pequi/use_cases/get_daily_medication_summary.py @@ -0,0 +1,52 @@ +# pequi/use_cases/get_daily_medication_summary.py +from datetime import date +from uuid import UUID + +from pequi.core.exceptions import NotFoundError +from pequi.repositories.daily_medication_progress_repo import ( + DailyMedicationProgressRepository, +) +from pequi.repositories.patient_repo import PatientRepository +from pequi.schemas.daily_medication_progress import DailyMedicationSummaryResponse + + +class GetDailyMedicationSummaryUseCase: + def __init__( + self, + progress_repo: DailyMedicationProgressRepository, + patient_repo: PatientRepository, + ) -> None: + self._progress_repo = progress_repo + self._patient_repo = patient_repo + + async def execute( + self, + user_id: UUID, + progress_date: date, + ) -> DailyMedicationSummaryResponse: + patient = await self._patient_repo.get_by_user_id(user_id) + if patient is None: + raise NotFoundError("PatientProfile") + + progress = await self._progress_repo.get_by_patient_and_date( + patient.id, + progress_date, + ) + + if progress is None: + return DailyMedicationSummaryResponse( + progress_date=progress_date, + expected_count=0, + taken_count=0, + remaining_count=0, + completed=False, + ) + + return DailyMedicationSummaryResponse( + progress_date=progress.progress_date, + expected_count=progress.expected_count, + taken_count=progress.taken_count, + remaining_count=max(progress.expected_count - progress.taken_count, 0), + completed=progress.expected_count > 0 + and progress.taken_count == progress.expected_count, + ) diff --git a/backend/src/pequi/use_cases/get_patient_journey.py b/backend/src/pequi/use_cases/get_patient_journey.py index 47fb475..c87b94e 100644 --- a/backend/src/pequi/use_cases/get_patient_journey.py +++ b/backend/src/pequi/use_cases/get_patient_journey.py @@ -1,49 +1,321 @@ +from datetime import UTC, date, datetime, timedelta from uuid import UUID -from pequi.core.exceptions import NotFoundError -from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.checkin_repo import CheckinRepository +from pequi.repositories.daily_medication_progress_repo import DailyMedicationProgressRepository from pequi.repositories.health_appointment_repo import HealthAppointmentRepository -from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository -from pequi.schemas.journey import JourneyResponse -from pequi.services.journey_service import JourneyService +from pequi.schemas.checkin import CheckinResponse +from pequi.schemas.patient_journey import ( + JourneyEvent, + JourneyMedicationSummary, + JourneyMonth, + JourneySummary, + PatientJourneyResponse, +) class GetPatientJourneyUseCase: - """Retorna a jornada de tratamento do paciente autenticado.""" - def __init__( self, patient_repo: PatientRepository, treatment_repo: TreatmentRepository, - dose_repo: DoseRepository, appointment_repo: HealthAppointmentRepository, - journey_event_repo: JourneyEventRepository, + checkin_repo: CheckinRepository, + daily_progress_repo: DailyMedicationProgressRepository, ) -> None: self._patient_repo = patient_repo self._treatment_repo = treatment_repo - self._dose_repo = dose_repo self._appointment_repo = appointment_repo - self._journey_event_repo = journey_event_repo + self._checkin_repo = checkin_repo + self._daily_progress_repo = daily_progress_repo - async def execute(self, user_id: UUID) -> JourneyResponse: + async def execute(self, user_id: UUID) -> PatientJourneyResponse: patient = await self._patient_repo.get_or_create_by_user_id(user_id) treatment = await self._treatment_repo.get_active_by_patient_id(patient.id) - if treatment is None: - raise NotFoundError("Treatment", "Nenhum tratamento ativo encontrado.") - - doses = await self._dose_repo.list_by_treatment(treatment.id) appointments = await self._appointment_repo.list_by_patient_id(patient.id) - snapshot = await self._treatment_repo.get_latest_adherence_snapshot(treatment.id) - journey_events = await self._journey_event_repo.list_for_treatment(patient.id, treatment.id) + checkins = await self._checkin_repo.list_history_by_patient_id( + patient.id, + limit=500, + offset=0, + ) + + treatment_start = self._resolve_treatment_start(patient, treatment) + classification = patient.classification + total_months = self._resolve_total_months(classification) + total_days = total_months * 30 + today = datetime.now(UTC).date() + + elapsed_days = 0 + remaining_days = total_days + progress_percent = 0 + current_month = 1 + estimated_end_date = None + treatment_status = None + + if treatment is not None: + treatment_status = ( + treatment.status.value + if hasattr(treatment.status, "value") + else str(treatment.status) + ) - return JourneyService.build_journey( + if treatment_start and total_days > 0: + elapsed_days = max(0, (today - treatment_start).days) + remaining_days = max(0, total_days - elapsed_days) + progress_percent = min(100, int((elapsed_days / total_days) * 100)) + current_month = min(total_months, max(1, (elapsed_days // 30) + 1)) + estimated_end_date = ( + treatment.expected_end + if treatment and treatment.expected_end is not None + else treatment_start + timedelta(days=total_days) + ) + + summary = JourneySummary( patient_id=patient.id, - patient=patient, - treatment=treatment, - doses=doses, - appointments=appointments, - journey_events=journey_events, - adherence_snapshot=snapshot, + user_id=patient.user_id, + display_name=self._resolve_display_name(patient), + classification=classification, + diagnosis_date=patient.diagnosis_date, + treatment_start_date=treatment_start, + estimated_end_date=estimated_end_date, + treatment_status=treatment_status, + treatment_duration_months=total_months, + total_days=total_days, + elapsed_days=elapsed_days, + remaining_days=remaining_days, + progress_percent=progress_percent, + current_month=current_month, + ) + + if not treatment_start or total_months == 0: + return PatientJourneyResponse(summary=summary, months=[]) + + daily_progress_logs = await self._daily_progress_repo.list_by_patient_id(patient.id) + + months: list[JourneyMonth] = [] + for month_index in range(1, total_months + 1): + month_start = treatment_start + timedelta(days=(month_index - 1) * 30) + month_end = month_start + timedelta(days=29) + + month_appointments = [ + item for item in appointments if month_start <= item.appointment_date <= month_end + ] + + month_checkins = [ + item for item in checkins if month_start <= item.checked_in_at.date() <= month_end + ] + + month_progress_logs = [ + item + for item in daily_progress_logs + if month_start <= item.progress_date <= month_end + ] + + if month_index < current_month: + month_status = "completed" + elif month_index == current_month: + month_status = "current" + else: + month_status = "upcoming" + + medication_summary = self._build_medication_summary(month_progress_logs) + + events = self._build_month_events( + month_index=month_index, + treatment_start=treatment_start, + month_end=month_end, + month_appointments=month_appointments, + month_checkins=month_checkins, + medication_summary=medication_summary, + ) + + months.append( + JourneyMonth( + month_index=month_index, + label=f"Mês {month_index}", + start_date=month_start, + end_date=month_end, + status=month_status, + medication_summary=medication_summary, + events=events, + ) + ) + + return PatientJourneyResponse(summary=summary, months=months) + + def _resolve_total_months(self, classification: str | None) -> int: + if classification == "PB": + return 6 + if classification == "MB": + return 12 + return 0 + + def _resolve_treatment_start(self, patient, treatment) -> date | None: + if treatment is not None and treatment.start_date is not None: + return treatment.start_date + + record = patient.treatment_record if isinstance(patient.treatment_record, dict) else {} + start = record.get("treatment_start_date") + if start: + return date.fromisoformat(start) + + return None + + def _resolve_display_name(self, patient) -> str | None: + personal = patient.personal_record if isinstance(patient.personal_record, dict) else {} + return personal.get("social_name") or None + + def _build_medication_summary(self, month_progress_logs) -> JourneyMedicationSummary: + total_days_in_month_window = 30 + completed_days = 0 + + for progress in month_progress_logs: + if progress.expected_count > 0 and progress.taken_count == progress.expected_count: + completed_days += 1 + + adherence_percent = int((completed_days / total_days_in_month_window) * 100) + + return JourneyMedicationSummary( + doses_taken=completed_days, + doses_expected=total_days_in_month_window, + adherence_percent=adherence_percent, + ) + + def _build_month_events( + self, + month_index: int, + treatment_start: date, + month_end: date, + month_appointments: list, + month_checkins: list[CheckinResponse], + medication_summary: JourneyMedicationSummary, + ) -> list[JourneyEvent]: + events: list[JourneyEvent] = [] + + if month_index == 1: + events.append( + JourneyEvent( + id=f"treatment-start-{month_index}", + type="treatment-start", + date=treatment_start, + title="Início do tratamento", + description="Seu tratamento foi iniciado e sua jornada começou.", + status="positive", + ) + ) + + for appointment in month_appointments: + title = "Consulta realizada" if appointment.performed else "Consulta agendada" + description = f"{appointment.appointment_type} em {appointment.location}" + + if appointment.professional: + description += f" com {appointment.professional}" + + events.append( + JourneyEvent( + id=str(appointment.id), + type="appointment", + date=appointment.appointment_date, + title=title, + description=description, + status="neutral", + metadata={ + "appointment_type": appointment.appointment_type, + "location": appointment.location, + "professional": appointment.professional, + "performed": appointment.performed, + "status": appointment.status, + "follow_up": appointment.follow_up, + }, + ) + ) + + events.append( + JourneyEvent( + id=f"medication-summary-{month_index}", + type="medication-summary", + date=month_end, + title=f"Resumo de medicação do mês {month_index}", + description=( + f"Você completou {medication_summary.doses_taken} de 30 dias do mês " + f"tomando todas as medicações esperadas." + ), + status="positive" if medication_summary.adherence_percent >= 80 else "neutral", + metadata={ + "dosesTaken": medication_summary.doses_taken, + "dosesExpected": medication_summary.doses_expected, + "adherencePercent": medication_summary.adherence_percent, + }, + ) ) + + trend = self._infer_checkin_trend(month_checkins) + + if trend == "improved": + events.append( + JourneyEvent( + id=f"clinical-improved-{month_index}", + type="clinical-update", + date=month_end, + title="Melhora percebida neste mês", + description="Os registros indicam melhora da intensidade dos sintomas neste período.", # noqa: E501 + status="positive", + ) + ) + events.append( + JourneyEvent( + id=f"support-message-{month_index}", + type="motivational-message", + date=month_end, + title="Continue seguindo seu tratamento", + description="Manter a regularidade ajuda a sustentar sua melhora.", + status="positive", + ) + ) + + elif trend == "worsened": + events.append( + JourneyEvent( + id=f"clinical-worsened-{month_index}", + type="clinical-update", + date=month_end, + title="Atenção aos sintomas", + description="Os registros indicam piora da intensidade dos sintomas neste período.", # noqa: E501 + status="attention", + ) + ) + events.append( + JourneyEvent( + id=f"alert-message-{month_index}", + type="motivational-message", + date=month_end, + title="Siga monitorando sua evolução", + description="Continue registrando seus sintomas e compartilhe essas informações na próxima consulta.", # noqa: E501 + status="attention", + ) + ) + + events.sort( + key=lambda item: ( + item.date + if isinstance(item.date, datetime) + else datetime.combine(item.date, datetime.min.time()) + ) + ) + return events + + def _infer_checkin_trend(self, month_checkins: list[CheckinResponse]) -> str | None: + if len(month_checkins) < 2: + return None + + ordered = sorted(month_checkins, key=lambda item: item.checked_in_at) + first = ordered[0].symptom_intensity + last = ordered[-1].symptom_intensity + + if last <= first - 2: + return "improved" + if last >= first + 2: + return "worsened" + return None diff --git a/backend/src/pequi/use_cases/get_treatment_journey.py b/backend/src/pequi/use_cases/get_treatment_journey.py new file mode 100644 index 0000000..46f2327 --- /dev/null +++ b/backend/src/pequi/use_cases/get_treatment_journey.py @@ -0,0 +1,55 @@ +from uuid import UUID + +from pequi.core.exceptions import NotFoundError +from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.health_appointment_repo import HealthAppointmentRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.journey import JourneyResponse +from pequi.services.journey_service import JourneyService + + +class GetTreatmentJourneyUseCase: + """Monta a jornada clínica com doses, consultas e eventos persistidos.""" + + def __init__( + self, + patient_repo: PatientRepository, + treatment_repo: TreatmentRepository, + dose_repo: DoseRepository, + appointment_repo: HealthAppointmentRepository, + journey_event_repo: JourneyEventRepository, + ) -> None: + self._patient_repo = patient_repo + self._treatment_repo = treatment_repo + self._dose_repo = dose_repo + self._appointment_repo = appointment_repo + self._journey_event_repo = journey_event_repo + + async def execute(self, user_id: UUID) -> JourneyResponse: + patient = await self._patient_repo.get_by_user_id(user_id) + if patient is None: + raise NotFoundError("PatientProfile") + + treatment = await self._treatment_repo.get_active_by_patient_id(patient.id) + if treatment is None: + raise NotFoundError("Treatment") + + doses = await self._dose_repo.list_by_treatment(treatment.id) + appointments = await self._appointment_repo.list_by_patient_id(patient.id) + adherence = await self._treatment_repo.get_latest_adherence_snapshot(treatment.id) + journey_events = await self._journey_event_repo.list_for_treatment( + patient.id, + treatment.id, + ) + + return JourneyService.build_journey( + patient_id=patient.id, + treatment=treatment, + doses=doses, + appointments=appointments, + adherence_snapshot=adherence, + patient=patient, + journey_events=journey_events, + ) diff --git a/backend/src/pequi/use_cases/upsert_daily_medication_progress.py b/backend/src/pequi/use_cases/upsert_daily_medication_progress.py new file mode 100644 index 0000000..bdf308b --- /dev/null +++ b/backend/src/pequi/use_cases/upsert_daily_medication_progress.py @@ -0,0 +1,51 @@ +# pequi/use_cases/upsert_daily_medication_progress.py +from uuid import UUID + +from sqlalchemy.exc import IntegrityError + +from pequi.core.exceptions import NotFoundError, ValidationFailedError +from pequi.repositories.daily_medication_progress_repo import ( + DailyMedicationProgressRepository, +) +from pequi.repositories.patient_repo import PatientRepository +from pequi.schemas.daily_medication_progress import ( + DailyMedicationProgressResponse, + DailyMedicationProgressUpsert, + daily_medication_progress_to_response, +) + + +class UpsertDailyMedicationProgressUseCase: + def __init__( + self, + progress_repo: DailyMedicationProgressRepository, + patient_repo: PatientRepository, + ) -> None: + self._progress_repo = progress_repo + self._patient_repo = patient_repo + + async def execute( + self, + user_id: UUID, + data: DailyMedicationProgressUpsert, + ) -> DailyMedicationProgressResponse: + patient = await self._patient_repo.get_by_user_id(user_id) + if patient is None: + raise NotFoundError("PatientProfile") + + if data.taken_count > data.expected_count: + raise ValidationFailedError("taken_count não pode ser maior que expected_count.") + + try: + progress = await self._progress_repo.upsert( + patient_id=patient.id, + progress_date=data.progress_date, + expected_count=data.expected_count, + taken_count=data.taken_count, + ) + except IntegrityError as exc: + raise ValidationFailedError( + "Não foi possível salvar o progresso diário de medicação." + ) from exc + + return daily_medication_progress_to_response(progress) diff --git a/backend/tests/e2e/test_journey_endpoint.py b/backend/tests/e2e/test_journey_endpoint.py index 0a04573..087451b 100644 --- a/backend/tests/e2e/test_journey_endpoint.py +++ b/backend/tests/e2e/test_journey_endpoint.py @@ -53,8 +53,10 @@ async def test_patient_journey_endpoint_matches_frontend_contract( assert response.status_code == 200 payload = response.json() - assert payload["patient"]["id"] == str(patient.id) - assert payload["treatment"]["regimen"] == "PB" - assert payload["months"][0]["month_number"] == 6 - assert "is_current" in payload["months"][0] - assert payload["summary"]["total_months"] == 6 + assert payload["summary"]["patient_id"] == str(patient.id) + assert payload["summary"]["classification"] == "PB" + assert payload["summary"]["treatment_duration_months"] == 6 + assert len(payload["months"]) == 6 + assert payload["months"][0]["month_index"] == 1 + assert payload["months"][-1]["month_index"] == 6 + assert "medication_summary" in payload["months"][0] diff --git a/backend/tests/integration/test_body_map.py b/backend/tests/integration/test_body_map.py index 2bbfc40..f986dd3 100644 --- a/backend/tests/integration/test_body_map.py +++ b/backend/tests/integration/test_body_map.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from pequi.core.auth import create_access_token -from pequi.models.body_map import BodyArea, BodySide, BodySystemPart +from pequi.models.body_map import BodyArea, BodySide, BodySystemPart, BodyView from pequi.models.symptom import Symptom, SymptomCategory from tests.integration.test_dose_flow import ( _create_health_unit, @@ -30,6 +30,9 @@ async def _create_body_area( label: str, side: BodySide, system_part: BodySystemPart, + x: int = 50, + y: int = 50, + view: BodyView = BodyView.front, ) -> BodyArea: area = BodyArea( id=uuid4(), @@ -37,6 +40,9 @@ async def _create_body_area( label=label, side=side, system_part=system_part, + x=x, + y=y, + view=view, ) session.add(area) await session.flush() @@ -80,6 +86,9 @@ async def test_body_areas_and_body_map_flow(async_client: AsyncClient, db_sessio list_areas = await async_client.get("/v1/body-areas", headers=headers) assert list_areas.status_code == 200 assert len(list_areas.json()) >= 2 + assert list_areas.json()[0]["view"] in {"front", "back"} + assert 0 <= list_areas.json()[0]["x"] <= 100 + assert 0 <= list_areas.json()[0]["y"] <= 100 update = await async_client.put( "/v1/body-map", diff --git a/backend/tests/integration/test_journey_flow.py b/backend/tests/integration/test_journey_flow.py index 14b7560..b58d5ac 100644 --- a/backend/tests/integration/test_journey_flow.py +++ b/backend/tests/integration/test_journey_flow.py @@ -18,7 +18,7 @@ from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.dose_log import DoseLogCreate -from pequi.use_cases.get_patient_journey import GetPatientJourneyUseCase +from pequi.use_cases.get_treatment_journey import GetTreatmentJourneyUseCase from pequi.use_cases.register_dose import RegisterDoseUseCase @@ -71,8 +71,8 @@ async def _create_treatment(session, *, patient: PatientProfile) -> Treatment: return treatment -def _journey_use_case(session) -> GetPatientJourneyUseCase: - return GetPatientJourneyUseCase( +def _journey_use_case(session) -> GetTreatmentJourneyUseCase: + return GetTreatmentJourneyUseCase( PatientRepository(session), TreatmentRepository(session), DoseRepository(session), diff --git a/backend/tests/unit/test_body_map_schema.py b/backend/tests/unit/test_body_map_schema.py index ff01249..df0720e 100644 --- a/backend/tests/unit/test_body_map_schema.py +++ b/backend/tests/unit/test_body_map_schema.py @@ -1,7 +1,7 @@ import pytest from pydantic import ValidationError -from pequi.schemas.body_map import BodyMapUpdateRequest +from pequi.schemas.body_map import BodyAreaResponse, BodyMapUpdateRequest def test_intensity_must_be_between_0_and_3(): @@ -50,3 +50,22 @@ def test_valid_payload_is_accepted(): assert payload.entries[0].intensity == 3 assert payload.entries[0].finding_type.value == "lesion" + + +def test_body_area_response_includes_display_position_and_view(): + area = BodyAreaResponse.model_validate( + { + "id": "5e5e2316-0fcc-4a3d-a2b4-51b856f6bf26", + "code": "face", + "label": "Face", + "side": "center", + "system_part": "head", + "x": 50, + "y": 10, + "view": "front", + } + ) + + assert area.x == 50 + assert area.y == 10 + assert area.view.value == "front" diff --git a/frontend/src/app/components/app-header/app-header.html b/frontend/src/app/components/app-header/app-header.html index 41f2dc9..763e64e 100644 --- a/frontend/src/app/components/app-header/app-header.html +++ b/frontend/src/app/components/app-header/app-header.html @@ -3,7 +3,7 @@ data-testid="app-header" > -
diff --git a/frontend/src/app/features/home/home.css b/frontend/src/app/features/home/home.css index 4642ea0..18029a5 100644 --- a/frontend/src/app/features/home/home.css +++ b/frontend/src/app/features/home/home.css @@ -1,9 +1,3 @@ -.container { - padding: 20px; - max-width: 500px; - margin: 0 auto; -} - .calendar-section { margin-bottom: 24px; } @@ -185,12 +179,21 @@ margin-bottom: 32px; } +.analysis-section-title { + margin: 0 0 16px; + font-size: 1.4rem; + font-weight: 700; + color: #373831; +} + .analysis-card { - background-color: #f1f0e8; + background-color: #fff; + border: 1px solid #e7e5e4; border-radius: 28px; padding: 16px; display: flex; flex-direction: column; + box-shadow: 0 4px 16px rgba(55, 56, 49, 0.04); } .analysis-image-wrapper { @@ -198,13 +201,14 @@ height: 200px; border-radius: 20px; overflow: hidden; - margin-bottom: 24px; + margin-bottom: 20px; + background-color: #eef2ff; } .mock-image-bg { width: 100%; height: 100%; - background: radial-gradient(circle at center, #35a3b5 0%, #174d64 100%); + background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 55%, #eef2ff 100%); } .analysis-image-wrapper img { @@ -214,51 +218,54 @@ } .analysis-content { - padding: 0 8px 8px 8px; + padding: 0 4px 4px; } -.analysis-tag { - display: block; - color: #3b6b82; - font-size: 0.8rem; - font-weight: 700; - letter-spacing: 1px; - text-transform: uppercase; - margin-bottom: 16px; +.analysis-topic { + display: inline-block; + margin-bottom: 12px; + border-radius: 999px; + background-color: #eef2ff; + padding: 6px 12px; + color: #4338ca; + font-size: 0.75rem; + font-weight: 600; } .analysis-title { - font-size: 1.6rem; - color: #333; + font-size: 1.35rem; + color: #373831; font-weight: 700; - line-height: 1.25; - margin: 0 0 16px 0; + line-height: 1.3; + margin: 0 0 12px; } .analysis-desc { font-size: 0.95rem; - color: #666; + color: #64655c; line-height: 1.5; - margin: 0 0 24px 0; + margin: 0 0 20px; } .analysis-btn { - background-color: #fff; - color: #5b48d9; + background-color: #eef2ff; + color: #4338ca; border: none; padding: 12px 24px; border-radius: 24px; font-weight: 600; font-size: 1rem; cursor: pointer; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.03); transition: all 0.2s ease; align-self: flex-start; } +.analysis-btn:hover { + background-color: #e0e7ff; +} + .analysis-btn:active { transform: scale(0.97); - background-color: #fafafa; } .summary-section { @@ -356,6 +363,7 @@ .summary-card__title { font-size: 17px; + max-width: none; } .summary-card__subtitle { diff --git a/frontend/src/app/features/home/home.html b/frontend/src/app/features/home/home.html index 1fe17da..1ba5c78 100644 --- a/frontend/src/app/features/home/home.html +++ b/frontend/src/app/features/home/home.html @@ -1,4 +1,4 @@ -
+ {{ event.description }} +
+ + + {{ event.time | date:'HH:mm' }} + +Nenhum registro encontrado para este dia.
+{{ weeklyArticle.description }}
+{{ article.summary }}
+ + +journey works!
++ Acompanhe sua evolução no tratamento mês a mês e registre cada etapa + importante da sua jornada. +
+ ++ {{ emptyJourneyTitle() }} +
++ {{ emptyJourneyMessage() }} + + {{ emptyJourneyLinkLabel() }} + + . +
++ {{ progressHeadline() }} +
+ ++ {{ progressSupportText() }} +
++ Acompanhamento atual +
+ ++ Seu tipo de hanseníase e a estimativa total de tratamento orientam os + marcos desta jornada para tornar o progresso mais claro. +
++ {{ event.date | date:'dd MMM yyyy' }} +
+ ++ {{ event.description }} +
+ + @if (event.type === 'medication-summary' && event.metadata) { +