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/113_repair_notifications_enabled.py b/backend/alembic/versions/113_repair_notifications_enabled.py new file mode 100644 index 0000000..6130f29 --- /dev/null +++ b/backend/alembic/versions/113_repair_notifications_enabled.py @@ -0,0 +1,29 @@ +"""repair missing notifications_enabled column + +Revision ID: 113_repair_notifications_enabled +Revises: aeb509f804c0 +Create Date: 2026-06-09 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "113_repair_notifications_enabled" +down_revision: str | None = "aeb509f804c0" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + """ + ALTER TABLE patient_profiles + ADD COLUMN IF NOT EXISTS notifications_enabled BOOLEAN NOT NULL DEFAULT true + """ + ) + + +def downgrade() -> None: + # The previous schema already expects this column from migration 101. + pass 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/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..44e35e9 100644 --- a/backend/src/pequi/routers/journey.py +++ b/backend/src/pequi/routers/journey.py @@ -24,10 +24,10 @@ async def get_journey( session: AsyncSession = Depends(get_db), ) -> JourneyResponse: use_case = GetPatientJourneyUseCase( - PatientRepository(session), - TreatmentRepository(session), - DoseRepository(session), - HealthAppointmentRepository(session), - JourneyEventRepository(session), + patient_repo=PatientRepository(session), + treatment_repo=TreatmentRepository(session), + dose_repo=DoseRepository(session), + appointment_repo=HealthAppointmentRepository(session), + journey_event_repo=JourneyEventRepository(session), ) return await use_case.execute(patient_user_id) diff --git a/backend/src/pequi/routers/patient.py b/backend/src/pequi/routers/patient.py index adc761d..d2ae9f3 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,16 +6,25 @@ from pequi.core.dependencies import get_current_patient, get_db from pequi.core.rate_limit import limiter +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.journey import JourneyResponse from pequi.schemas.patient import PatientProfileRead, PatientProfileUpdate from pequi.schemas.patient_personal import ( PatientPersonalRecordRead, @@ -26,6 +36,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 +57,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 +73,18 @@ def _treatment_repos( ) +def _daily_medication_progress_repos( + session: AsyncSession, +) -> tuple[ + DailyMedicationProgressRepository, + PatientRepository, +]: + return ( + DailyMedicationProgressRepository(session), + PatientRepository(session), + ) + + @router.get("/me", response_model=PatientProfileRead) async def get_my_profile( user_id: UUID = Depends(get_current_patient), @@ -217,3 +246,46 @@ async def update_my_appointment( JourneyEventRepository(session), ) return await use_case.execute(user_id, appointment_id, body) + + +@router.get("/me/journey", response_model=JourneyResponse) +@limiter.limit("100/minute") +async def get_my_journey( + request: Request, + user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> JourneyResponse: + use_case = GetPatientJourneyUseCase( + patient_repo=PatientRepository(session), + treatment_repo=TreatmentRepository(session), + dose_repo=DoseRepository(session), + appointment_repo=HealthAppointmentRepository(session), + journey_event_repo=JourneyEventRepository(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..cdfff3a 100644 --- a/backend/src/pequi/use_cases/get_patient_journey.py +++ b/backend/src/pequi/use_cases/get_patient_journey.py @@ -11,7 +11,7 @@ class GetPatientJourneyUseCase: - """Retorna a jornada de tratamento do paciente autenticado.""" + """Retorna a jornada do tratamento ativo do paciente autenticado.""" def __init__( self, 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/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/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/features/checkin/services/checkin.service.ts b/frontend/src/app/features/checkin/services/checkin.service.ts index c501fed..f6670f7 100644 --- a/frontend/src/app/features/checkin/services/checkin.service.ts +++ b/frontend/src/app/features/checkin/services/checkin.service.ts @@ -95,6 +95,10 @@ export class CheckinService { return this.http.post(`${this.apiUrl}/v1/checkins`, payload); } + getCheckinHistory(): Observable { + return this.http.get(`${this.apiUrl}/v1/checkins`); + } + resolveSymptomIds(selectedNames: string[], catalog: SymptomResponse[]): string[] { if (!catalog.length) { return []; @@ -147,4 +151,4 @@ export class CheckinService { .replace(/[\u0300-\u036f]/g, '') .replace(/\s+/g, ' '); } -} \ No newline at end of file +} diff --git a/frontend/src/app/features/home/home.html b/frontend/src/app/features/home/home.html index 1fe17da..56809ea 100644 --- a/frontend/src/app/features/home/home.html +++ b/frontend/src/app/features/home/home.html @@ -44,9 +44,14 @@

{{ currentMonthYear }} > {{ day.dayName }} {{ day.dayNumber }} -
- @for (dot of day.dots; track $index) { -
+
+ @for (dotType of day.dots; track $index) { + + }
@@ -65,23 +70,37 @@

{{ currentMonthYear }}
Sáb
-
+
@for (day of calendarMonth; track $index) { @if (day) { -
+ - {{ day.dayNumber }} -
- @for (dot of day.dots; track $index) { -
- } -
+ {{ day.dayNumber }} +
+ +
+ @for (dotType of day.dots; track $index) { + + + }
+ } @else { -
+
} }
@@ -89,11 +108,45 @@

{{ currentMonthYear }} } +
+

+ Registros do dia {{ selectedDate | date:'dd/MM' }} +

+ + @if (selectedDayEvents().length > 0) { +
+ @for (event of selectedDayEvents(); track event.id) { +
+ +
+ +
+ +
+

{{ event.title }}

+

+ {{ event.description }} +

+ + + {{ event.time | date:'HH:mm' }} + +
+
+ } +
+ } @else { +
+

Nenhum registro encontrado para este dia.

+
+ } +
+
-
- {{ medicationSummaryCard.value }} - {{ medicationSummaryCard.title }} +
+ {{ medicationSummaryCard().value }} + {{ medicationSummaryCard().title }}
= { + 'great': 'Ótimo', + 'good': 'Muito Bem', + 'ok': 'Normal', + 'bad': 'Ruim', + 'terrible': 'Péssimo' + }; + + translateMood(mood: string): string { + if (!mood) return 'Não registrado'; + return this.moodMap[mood.toLowerCase()] || mood; + } @ViewChild('daysRow') daysRow!: ElementRef; @@ -74,11 +104,32 @@ export class HomeComponent implements OnInit, AfterViewInit { calendarMonth: (CalendarDay | null)[] = []; selectedDate: Date = new Date(); - readonly medicationSummaryCard: HomeHighlightCard = { - value: '2/4', - title: 'Medicações tomadas', - backgroundClass: 'summary-card--purple', - }; + monthDotsMap = signal>({}); + allCheckins = signal([]); + allAppointments = signal([]); + selectedDayEvents = signal([]); + + readonly medicationSummary = signal(null); + + readonly medicationSummaryCard = computed(() => { + const summary = this.medicationSummary(); + + if (!summary || summary.expected_count === 0) { + return { + value: '0/0', + title: 'Medicações tomadas', + subtitle: 'Nenhuma dose esperada para hoje', + backgroundClass: 'summary-card--purple', + }; + } + + return { + value: `${summary.taken_count}/${summary.expected_count}`, + title: 'Medicações tomadas', + subtitle: summary.completed ? 'Todas as doses do dia foram marcadas' : 'Progresso de hoje', + backgroundClass: 'summary-card--purple', + }; + }); readonly nextAppointmentCard = computed(() => { const next = resolveNextAppointment(this.appointmentService.appointments()); @@ -111,7 +162,7 @@ export class HomeComponent implements OnInit, AfterViewInit { colorClass: 'blue-icon', path: '/checkin', }, - { + { title: 'Registrar medicamentos', description: 'Veja quais remédios tomar hoje', icon: this.Pill, @@ -175,6 +226,14 @@ export class HomeComponent implements OnInit, AfterViewInit { this.generateCurrentWeek(); this.generateCurrentMonth(); this.updateMonthYearLabel(); + this.loadDailyMedicationSummary(); + this.appointmentService.syncFromApi().subscribe({ + next: (appointments) => { + this.allAppointments.set(appointments); + this.rebuildDotsMap(); + } + }); + this.fetchMonthData(); } ngAfterViewInit(): void { @@ -182,21 +241,97 @@ export class HomeComponent implements OnInit, AfterViewInit { } toggleCalendar() { - this.isExpanded.update(val => !val); + this.isExpanded.update((val) => !val); if (!this.isExpanded()) { this.centerActiveDay(); } } + fetchMonthData() { + this.checkinService.getCheckinHistory().subscribe({ + next: (response) => { + const checkinsList = Array.isArray(response) ? response : response.items || []; + this.allCheckins.set(checkinsList); + + this.rebuildDotsMap(); + }, + error: (err) => console.error('Erro ao buscar check-ins:', err) + }); + } + + rebuildDotsMap() { + const dotsMap: Record = {}; + + this.allCheckins().forEach((checkin: any) => { + const dateField = checkin.created_at || checkin.date; + if (dateField) { + const dateKey = dateField.split('T')[0]; + if (!dotsMap[dateKey]) dotsMap[dateKey] = []; + dotsMap[dateKey].push('checkin'); + } + }); + + this.allAppointments().forEach((apt: HealthAppointment) => { + const dateField = apt.appointmentDate; + if (dateField) { + const dateKey = dateField.split('T')[0]; + if (!dotsMap[dateKey]) dotsMap[dateKey] = []; + dotsMap[dateKey].push('appointment'); + } + }); + + this.monthDotsMap.set(dotsMap); + this.generateCurrentWeek(); + this.generateCurrentMonth(); + this.filterEventsForSelectedDate(); + } + + filterEventsForSelectedDate() { + const clickedDateStr = this.getLocalIsoDate(this.selectedDate); + const mergedEvents: any[] = []; + + this.allCheckins().forEach(checkin => { + const dateField = checkin.created_at || checkin.date; + if (dateField && dateField.split('T')[0] === clickedDateStr) { + mergedEvents.push({ + type: 'checkin', + id: checkin.id, + time: dateField, + title: 'Check-in de Saúde', + description: checkin.notes || 'Humor: ' + this.translateMood(checkin.mood), + icon: this.CirclePlus, + colorClass: 'text-[#0EA5E9] bg-[#E0F2FE] border-[#0EA5E9]' + }); + } + }); + + this.allAppointments().forEach(apt => { + const dateField = apt.appointmentDate; + if (dateField && dateField.split('T')[0] === clickedDateStr) { + mergedEvents.push({ + type: 'appointment', + id: apt.id, + time: apt.appointmentTime ? `${dateField}T${apt.appointmentTime}` : dateField, + title: apt.type === 'exame' ? 'Exame' : apt.type === 'retorno' ? 'Retorno' : 'Consulta', + description: `Local: ${apt.location || 'Não informado'} ${apt.professional ? '- ' + apt.professional : ''}`, + icon: this.Stethoscope, + colorClass: 'text-[#9333EA] bg-[#F3E8FF] border-[#9333EA]' + }); + } + }); + mergedEvents.sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()); + + this.selectedDayEvents.set(mergedEvents); + } + changeMonth(delta: number) { const newDate = new Date(this.selectedDate); newDate.setMonth(newDate.getMonth() + delta); this.selectedDate = newDate; - + this.updateMonthYearLabel(); - this.generateCurrentWeek(); - this.generateCurrentMonth(); + this.fetchMonthData(); } goToToday() { @@ -205,6 +340,7 @@ export class HomeComponent implements OnInit, AfterViewInit { this.generateCurrentWeek(); this.generateCurrentMonth(); this.centerActiveDay(); + this.loadDailyMedicationSummary(); } centerActiveDay() { @@ -215,19 +351,24 @@ export class HomeComponent implements OnInit, AfterViewInit { const activeCard = container.querySelector('.day-card.active') as HTMLElement; if (activeCard) { - activeCard.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - inline: 'center' + activeCard.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'center', }); } }, 100); } + private getLocalIsoDate(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + } + generateCurrentWeek() { this.calendarWeek = []; - const currentDay = this.selectedDate.getDay(); - const startOfScroll = new Date(this.selectedDate); startOfScroll.setDate(this.selectedDate.getDate() - 10); @@ -237,11 +378,14 @@ export class HomeComponent implements OnInit, AfterViewInit { const dateObj = new Date(startOfScroll); dateObj.setDate(startOfScroll.getDate() + i); + const dateKey = this.getLocalIsoDate(dateObj); + const dotsForDay = this.monthDotsMap()[dateKey] || []; + this.calendarWeek.push({ dateObj, dayName: daysPt[dateObj.getDay()], dayNumber: dateObj.getDate(), - dots: Array(Math.floor(Math.random() * 3)).fill(0), + dots: dotsForDay, }); } } @@ -261,11 +405,15 @@ export class HomeComponent implements OnInit, AfterViewInit { for (let i = 1; i <= lastDayOfMonth.getDate(); i++) { const dateObj = new Date(year, month, i); + + const dateKey = this.getLocalIsoDate(dateObj); + const dotsForDay = this.monthDotsMap()[dateKey] || []; + this.calendarMonth.push({ dateObj, dayName: daysPt[dateObj.getDay()], dayNumber: i, - dots: Array(Math.floor(Math.random() * 3)).fill(0), + dots: dotsForDay, }); } } @@ -291,6 +439,9 @@ export class HomeComponent implements OnInit, AfterViewInit { selectDate(date: Date) { this.selectedDate = date; this.updateMonthYearLabel(); + this.generateCurrentWeek(); + this.centerActiveDay(); + this.filterEventsForSelectedDate(); } isSameDate(date1: Date, date2: Date): boolean { @@ -300,4 +451,24 @@ export class HomeComponent implements OnInit, AfterViewInit { date1.getFullYear() === date2.getFullYear() ); } + + private loadDailyMedicationSummary(): void { + this.dailyMedicationProgressService.getSummary(this.getTodayDate()).subscribe({ + next: (summary) => { + this.medicationSummary.set(summary); + }, + error: () => { + this.medicationSummary.set(null); + }, + }); + } + + private getTodayDate(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; + } } diff --git a/frontend/src/app/features/home/services/calendar.service.ts b/frontend/src/app/features/home/services/calendar.service.ts new file mode 100644 index 0000000..a084ac9 --- /dev/null +++ b/frontend/src/app/features/home/services/calendar.service.ts @@ -0,0 +1,17 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +@Injectable({ providedIn: 'root' }) +export class CalendarService { + private http = inject(HttpClient); + private apiUrl = 'http://localhost:8000/v1/calendar'; + + getMonthSummary(year: number, month: number): Observable> { + return this.http.get>(`${this.apiUrl}/summary?year=${year}&month=${month}`); + } + + getDayDetails(date: string): Observable { + return this.http.get(`${this.apiUrl}/day-details?target_date=${date}`); + } +} \ No newline at end of file diff --git a/frontend/src/app/features/journey/journey.html b/frontend/src/app/features/journey/journey.html index ae50a89..d5b3785 100644 --- a/frontend/src/app/features/journey/journey.html +++ b/frontend/src/app/features/journey/journey.html @@ -1 +1,297 @@ -

journey works!

+
+
+
+

+ Sua jornada +

+ +

+ Acompanhe sua evolução no tratamento mês a mês e registre cada etapa + importante da sua jornada. +

+ +
+ + {{ leprosyTypeLabel() }} + + + + {{ treatmentEstimateText() }} + +
+
+ + @if (shouldShowJourneySetupState()) { + + } @else { +
+
+
+

+ {{ progressHeadline() }} +

+ +

+ {{ progressSupportText() }} +

+
+ +
+
+ {{ progressPercent() }}% +
+
+ +
+ {{ remainingText() }} +
+
+
+ +
+
+ + +
+

+ Acompanhamento atual +

+ +

+ Você está no {{ currentMonth() }}º mês do tratamento +

+ +

+ Seu tipo de hanseníase e a estimativa total de tratamento orientam os + marcos desta jornada para tornar o progresso mais claro. +

+
+
+
+ +
+ @for (month of displayMonths(); track month.monthIndex) { +
+ + +
+ {{ month.monthIndex }} +
+ +
+ + + @if (month.expanded && !month.locked) { +
+ @if (month.events.length) { +
+ @for (event of month.events; track event.id) { +
+
+
+

+ {{ event.date | date:'dd MMM yyyy' }} +

+ +

+ {{ event.title }} +

+
+ + + {{ getEventTypeLabel(event.type) }} + +
+ +

+ {{ event.description }} +

+ + @if (event.type === 'medication-summary' && event.metadata) { +
+ Dias completos de medicação no ciclo: + {{ event.metadata.dosesTaken ?? 0 }} + @if (event.metadata.dosesExpected) { + + / {{ event.metadata.dosesExpected }} + + } +
+ } + @if (event.type === 'appointment' && event.metadata?.consultationLocation) { +
+ Local da consulta: + {{ event.metadata?.consultationLocation }} +
+ } +
+ } +
+ } @else { +
+ Nenhum registro foi adicionado neste mês até agora. +
+ } +
+ } +
+
+ } +
+ } +
+
\ No newline at end of file diff --git a/frontend/src/app/features/journey/journey.spec.ts b/frontend/src/app/features/journey/journey.spec.ts index 3ffefdc..e09d356 100644 --- a/frontend/src/app/features/journey/journey.spec.ts +++ b/frontend/src/app/features/journey/journey.spec.ts @@ -1,22 +1,222 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { Journey } from './journey'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Journey, JourneyEvent } from './journey'; describe('Journey', () => { - let component: Journey; let fixture: ComponentFixture; + let component: Journey; + + const mockEvents: JourneyEvent[] = [ + { + id: 'appointment-m1-001', + type: 'appointment', + title: 'Primeira consulta após início do tratamento', + description: 'Consulta inicial registrada.', + date: '2026-04-10', + status: 'neutral', + metadata: { + consultationLocation: 'UBS Benedito Bentes', + }, + }, + { + id: 'clinical-update-m1-001', + type: 'clinical-update', + title: 'Piora registrada em lesão cutânea', + description: 'Paciente relatou piora.', + date: '2026-04-14', + status: 'attention', + metadata: { + symptomTrend: 'worsened', + }, + }, + { + id: 'medication-summary-m1-001', + type: 'medication-summary', + title: 'Resumo de medicação do mês 1', + description: 'Resumo do primeiro mês.', + date: '2026-05-04', + status: 'positive', + metadata: { + dosesTaken: 28, + dosesExpected: 30, + }, + }, + { + id: 'appointment-m2-001', + type: 'appointment', + title: 'Consulta de acompanhamento do segundo mês', + description: 'Consulta do mês 2.', + date: '2026-05-12', + status: 'neutral', + metadata: { + consultationLocation: 'Ambulatório de Dermatologia Municipal', + }, + }, + { + id: 'clinical-update-m2-001', + type: 'clinical-update', + title: 'Melhora percebida pelo paciente', + description: 'Paciente relatou melhora.', + date: '2026-05-18', + status: 'positive', + metadata: { + symptomTrend: 'improved', + }, + }, + ]; beforeEach(async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-20T12:00:00')); + await TestBed.configureTestingModule({ imports: [Journey], }).compileComponents(); fixture = TestBed.createComponent(Journey); component = fixture.componentInstance; - await fixture.whenStable(); + + fixture.componentRef.setInput('patientName', 'José da Silva'); + fixture.componentRef.setInput('leprosyType', 'PB'); + fixture.componentRef.setInput('appStartDate', '2026-04-03'); + fixture.componentRef.setInput('treatmentStartDate', '2026-04-05'); + fixture.componentRef.setInput('events', mockEvents); + + fixture.detectChanges(); + }); + + afterEach(() => { + vi.useRealTimers(); + TestBed.resetTestingModule(); }); it('should create', () => { expect(component).toBeTruthy(); }); -}); + + it('should calculate PB treatment with 6 months and 180 days', () => { + expect(component.totalMonths()).toBe(6); + expect(component.totalDays()).toBe(180); + }); + + it('should calculate progress based on elapsed days', () => { + expect(component.elapsedDays()).toBe(45); + expect(component.currentMonth()).toBe(2); + expect(component.progressPercent()).toBe(25); + }); + + it('should show PB label and 6 month estimate', () => { + expect(component.leprosyTypeLabel()).toContain('paucibacilar'); + expect(component.treatmentEstimateText()).toContain('6 meses'); + }); + + it('should build 6 months for PB journey', () => { + expect(component.months()).toHaveLength(6); + expect(component.months()[0].label).toBe('Mês 1'); + expect(component.months()[5].label).toBe('Mês 6'); + }); + + it('should mark month 2 as current', () => { + const month2 = component.months().find((month) => month.monthIndex === 2); + const month1 = component.months().find((month) => month.monthIndex === 1); + const month3 = component.months().find((month) => month.monthIndex === 3); + + expect(month1?.completed).toBe(true); + expect(month2?.current).toBe(true); + expect(month3?.locked).toBe(true); + }); + + it('should include generated app start and treatment start events in month 1', () => { + const month1 = component.months().find((month) => month.monthIndex === 1); + + expect(month1).toBeTruthy(); + expect( + month1?.events.some((event) => event.type === 'app-start') + ).toBe(true); + expect( + month1?.events.some((event) => event.type === 'treatment-start') + ).toBe(true); + }); + + it('should generate motivational messages for worsened and improved symptom months', () => { + const month1 = component.months().find((month) => month.monthIndex === 1); + const month2 = component.months().find((month) => month.monthIndex === 2); + + expect( + month1?.events.some((event) => event.id === 'auto-attention-1') + ).toBe(true); + + expect( + month2?.events.some((event) => event.id === 'auto-improved-2') + ).toBe(true); + }); + + it('should summarize medication adherence for month 1', () => { + const month1 = component.months().find((month) => month.monthIndex === 1); + + expect(month1?.medicationTaken).toBe(28); + expect(month1?.medicationExpected).toBe(30); + expect(component.getMedicationAdherenceText(month1!)).toContain('93%'); + }); + + it('should toggle an unlocked month', () => { + const month1Before = component.months().find((month) => month.monthIndex === 1); + + expect(month1Before?.expanded).toBe(false); + + component.toggleMonth(month1Before!); + fixture.detectChanges(); + + const month1After = component.months().find((month) => month.monthIndex === 1); + + expect(month1After?.expanded).toBe(true); + }); + + it('should not toggle a locked month', () => { + const month3Before = component.months().find((month) => month.monthIndex === 3); + + expect(month3Before?.locked).toBe(true); + expect(month3Before?.expanded).toBe(false); + + component.toggleMonth(month3Before!); + fixture.detectChanges(); + + const month3After = component.months().find((month) => month.monthIndex === 3); + + expect(month3After?.expanded).toBe(false); + }); + + it('should render month items in template', () => { + const monthItems = fixture.nativeElement.querySelectorAll( + '[data-testid^="journey-month-"]' + ); + + expect(monthItems.length).toBeGreaterThanOrEqual(6); + }); + + it('should render progress information in template', () => { + const title = fixture.nativeElement.querySelector( + '[data-testid="journey-title"]' + ) as HTMLElement; + + const estimateBadge = fixture.nativeElement.querySelector( + '[data-testid="treatment-estimate-badge"]' + ) as HTMLElement; + + const progressCircle = fixture.nativeElement.querySelector( + '[data-testid="progress-circle"]' + ) as HTMLElement; + + expect(title.textContent).toContain('Sua jornada'); + expect(estimateBadge.textContent).toContain('6 meses'); + expect(progressCircle.textContent).toContain('25%'); + }); + + it('should render month 2 panel expanded by default', () => { + const panel = fixture.nativeElement.querySelector( + '[data-testid="journey-month-panel-2"]' + ) as HTMLElement | null; + + expect(panel).not.toBeNull(); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/features/journey/journey.ts b/frontend/src/app/features/journey/journey.ts index 2580dba..0cd72fd 100644 --- a/frontend/src/app/features/journey/journey.ts +++ b/frontend/src/app/features/journey/journey.ts @@ -1,10 +1,333 @@ -import { Component } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + OnInit, + computed, + inject, + signal, +} from '@angular/core'; +import { CommonModule, DatePipe } from '@angular/common'; +import { JourneyService } from './services/journey-service'; +import { RouterLink } from '@angular/router'; + +export type LeprosyType = 'PB' | 'MB' | ''; +export type JourneyEventType = + | 'treatment-start' + | 'appointment' + | 'medication-summary' + | 'clinical-update' + | 'motivational-message'; + +export type JourneyEventStatus = 'positive' | 'neutral' | 'attention'; +export type SymptomTrend = 'improved' | 'stable' | 'worsened'; + +export interface JourneyEvent { + id: string; + type: JourneyEventType; + title: string; + description: string; + date: string; + monthIndex?: number; + status?: JourneyEventStatus; + metadata?: { + dosesTaken?: number; + dosesExpected?: number; + symptomTrend?: SymptomTrend; + consultationLocation?: string; + }; +} + +export interface JourneyMonth { + monthIndex: number; + label: string; + expanded: boolean; + completed: boolean; + current: boolean; + locked: boolean; + events: JourneyEvent[]; + completedMedicationDays: number; + expectedMedicationDays: number; +} @Component({ selector: 'app-journey', standalone: true, - imports: [], + imports: [CommonModule, DatePipe, RouterLink], templateUrl: './journey.html', styleUrl: './journey.css', + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class Journey {} +export class Journey implements OnInit { + readonly journeyService = inject(JourneyService); + + readonly patient = this.journeyService.patient; + readonly apiMonths = this.journeyService.months; + readonly summary = this.journeyService.summary; + readonly isLoading = this.journeyService.isLoading; + readonly error = this.journeyService.error; + + readonly expandedMonths = signal>({}); + + ngOnInit(): void { + this.journeyService.loadJourney(); + } + + readonly patientName = computed(() => this.patient().name); + + readonly leprosyType = computed(() => { + return this.summary()?.classification ?? this.patient().leprosyType ?? ''; + }); + + readonly treatmentStartDate = computed( + () => this.summary()?.treatment_start_date ?? this.patient().treatmentStartDate + ); + + readonly events = computed(() => this.journeyService.events()); + + readonly totalMonths = computed(() => { + const apiValue = this.summary()?.treatment_duration_months; + if (apiValue) { + return apiValue; + } + return this.leprosyType() === 'PB' ? 6 : 12; + }); + + readonly totalDays = computed(() => { + return this.summary()?.total_days ?? this.totalMonths() * 30; + }); + + readonly elapsedDays = computed(() => this.summary()?.elapsed_days ?? 0); + + readonly remainingDays = computed(() => { + return this.summary()?.remaining_days ?? Math.max(0, this.totalDays() - this.elapsedDays()); + }); + + readonly progressPercent = computed(() => { + return this.summary()?.progress_percent ?? 0; + }); + + readonly currentMonth = computed(() => { + return this.summary()?.current_month ?? 1; + }); + + readonly estimatedEndDate = computed(() => { + return this.summary()?.estimated_end_date ?? ''; + }); + + readonly months = computed(() => { + const expandedMap = this.expandedMonths(); + + return this.apiMonths().map((month) => ({ + monthIndex: month.month_index, + label: month.label, + expanded: expandedMap[month.month_index] ?? month.status === 'current', + completed: month.status === 'completed', + current: month.status === 'current', + locked: month.status === 'upcoming', + events: month.events + .map((event) => ({ + id: event.id, + type: event.type, + title: event.title, + description: event.description, + date: event.date, + monthIndex: month.month_index, + status: event.status, + metadata: { + dosesTaken: event.metadata?.dosesTaken, + dosesExpected: event.metadata?.dosesExpected, + symptomTrend: event.metadata?.symptomTrend, + consultationLocation: + event.metadata?.consultationLocation ?? event.metadata?.location, + }, + })) + .sort((a, b) => +new Date(b.date) - +new Date(a.date)), + completedMedicationDays: month.medication_summary.doses_taken, + expectedMedicationDays: month.medication_summary.doses_expected, + })); + }); + + readonly hasTreatmentStartDate = computed(() => { + const value = this.treatmentStartDate(); + return !!value?.trim(); + }); + + readonly shouldShowJourneySetupState = computed(() => !this.hasTreatmentStartDate()); + + readonly emptyJourneyTitle = computed(() => + 'Sua jornada de tratamento ainda não começou' + ); + + readonly emptyJourneyMessage = computed( + () => 'Para acompanhar sua evolução, adicione a data de início do tratamento na tela de ' + ); + + readonly emptyJourneyLinkLabel = computed(() => 'Perfil > Meu tratamento'); + + readonly emptyJourneySupportMessage = computed( + () => + 'Depois de informar essa data, a linha do tempo será organizada automaticamente.' + ); + + readonly leprosyTypeLabel = computed(() => + this.leprosyType() === 'PB' + ? 'Hanseníase paucibacilar (PB)' + : 'Hanseníase multibacilar (MB)' + ); + + readonly treatmentEstimateText = computed(() => + this.totalMonths() === 6 + ? 'Estimativa de tratamento: 6 meses' + : 'Estimativa de tratamento: 12 meses' + ); + + readonly progressHeadline = computed(() => { + if (!this.hasTreatmentStartDate()) { + return 'Adicione a data de início do tratamento'; + } + + if (this.progressPercent() >= 80) { + return 'Você está avançando bem no tratamento'; + } + + if (this.progressPercent() >= 40) { + return 'Seu tratamento segue em andamento'; + } + + return 'Cada etapa cumprida fortalece sua jornada'; + }); + + readonly progressSupportText = computed(() => { + if (!this.hasTreatmentStartDate()) { + return 'Assim que essa data for informada, mostraremos seu progresso e os marcos da jornada.'; + } + + return `Você já percorreu ${this.elapsedDays()} de ${this.totalDays()} dias previstos do tratamento.`; + }); + + readonly remainingText = computed(() => { + if (!this.hasTreatmentStartDate()) { + return 'Acesse Perfil > Meu tratamento para informar a data e iniciar sua jornada visual.'; + } + + if (this.remainingDays() <= 0) { + return 'Tratamento previsto concluído.'; + } + + const remainingMonths = Math.ceil(this.remainingDays() / 30); + + return `Faltam aproximadamente ${this.remainingDays()} dias (${remainingMonths} ${ + remainingMonths === 1 ? 'mês' : 'meses' + }) para a estimativa final. Continue com o ótimo trabalho!`; + }); + + readonly displayMonths = computed(() => { + return this.months() + .filter((month) => month.current || month.completed) + .sort((a, b) => b.monthIndex - a.monthIndex); + }); + + toggleMonth(month: JourneyMonth): void { + if (month.locked) { + return; + } + + this.expandedMonths.update((current) => ({ + ...current, + [month.monthIndex]: !month.expanded, + })); + } + + getMonthStatusLabel(month: JourneyMonth): string { + if (month.current) { + return 'Mês atual'; + } + + if (month.completed) { + return 'Etapa concluída'; + } + + return 'Etapa futura'; + } + + getMonthSummary(month: JourneyMonth): string { + if (month.locked) { + return 'Este mês ainda não começou.'; + } + + if (!month.events.length) { + return 'Nenhum registro neste mês até agora.'; + } + + return `${month.events.length} registro(s) e ${month.completedMedicationDays}/${month.expectedMedicationDays} dia(s) completos no ciclo de 30 dias.`; + } + + getMedicationAdherenceText(month: JourneyMonth): string | null { + if (!month.expectedMedicationDays) { + return null; + } + + const percentage = Math.floor( + (month.completedMedicationDays / month.expectedMedicationDays) * 100 + ); + + return `Adesão registrada no mês: ${percentage}% (${month.completedMedicationDays}/${month.expectedMedicationDays} dias completos).`; + } + + getMonthButtonLabel(month: JourneyMonth): string { + if (month.locked) { + return 'Aguardando'; + } + + return month.expanded ? 'Ocultar' : 'Ver detalhes'; + } + + getEventContainerClass(status?: JourneyEventStatus): string { + switch (status) { + case 'positive': + return 'border-emerald-200 bg-emerald-50 text-emerald-900'; + case 'attention': + return 'border-amber-200 bg-amber-50 text-amber-900'; + default: + return 'border-slate-200 bg-slate-50 text-slate-900'; + } + } + + getEventBadgeClass(type: JourneyEventType): string { + switch (type) { + case 'appointment': + return 'bg-sky-100 text-sky-700'; + case 'treatment-start': + return 'bg-violet-100 text-violet-700'; + case 'medication-summary': + return 'bg-emerald-100 text-emerald-700'; + case 'clinical-update': + return 'bg-amber-100 text-amber-700'; + default: + return 'bg-indigo-100 text-indigo-700'; + } + } + + getEventTypeLabel(type: JourneyEventType): string { + switch (type) { + case 'appointment': + return 'Consulta'; + case 'treatment-start': + return 'Tratamento'; + case 'medication-summary': + return 'Medicação'; + case 'clinical-update': + return 'Evolução'; + default: + return 'Mensagem'; + } + } + + trackMonth(_: number, month: JourneyMonth): number { + return month.monthIndex; + } + + trackEvent(_: number, event: JourneyEvent): string { + return event.id; + } +} \ No newline at end of file diff --git a/frontend/src/app/features/journey/services/journey-service.spec.ts b/frontend/src/app/features/journey/services/journey-service.spec.ts new file mode 100644 index 0000000..c272cc6 --- /dev/null +++ b/frontend/src/app/features/journey/services/journey-service.spec.ts @@ -0,0 +1,241 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; + +import { JourneyService, JourneyData } from './journey-service'; + +describe('JourneyService', () => { + let service: JourneyService; + + const mockJourneyData: JourneyData = { + patient: { + id: 'patient-pb-001', + name: 'José da Silva', + leprosyType: 'PB', + treatmentStartDate: '2026-04-05', + }, + events: [ + { + id: 'appointment-m1-001', + type: 'appointment', + title: 'Consulta realizada', + description: 'Consulta na unidade de saúde.', + date: '2026-04-12', + monthIndex: 1, + status: 'neutral', + metadata: { + consultationLocation: 'UBS Centro', + }, + }, + { + id: 'medication-summary-m1-001', + type: 'medication-summary', + title: 'Resumo de medicação', + description: 'Resumo mensal de doses.', + date: '2026-04-30', + monthIndex: 1, + status: 'positive', + metadata: { + dosesTaken: 28, + dosesExpected: 30, + }, + }, + { + id: 'clinical-update-m2-001', + type: 'clinical-update', + title: 'Piora percebida', + description: 'Paciente relatou piora.', + date: '2026-05-10', + monthIndex: 2, + status: 'attention', + metadata: { + symptomTrend: 'worsened', + }, + }, + { + id: 'clinical-update-m2-002', + type: 'clinical-update', + title: 'Melhora percebida', + description: 'Paciente relatou melhora.', + date: '2026-05-18', + monthIndex: 2, + status: 'positive', + metadata: { + symptomTrend: 'improved', + }, + }, + { + id: 'motivational-message-m1-001', + type: 'motivational-message', + title: 'Continue assim', + description: 'Boa adesão ao tratamento.', + date: '2026-04-20', + monthIndex: 1, + status: 'positive', + }, + { + id: 'motivational-message-m2-001', + type: 'motivational-message', + title: 'Atenção aos sintomas', + description: 'Observe sinais e registre mudanças.', + date: '2026-05-12', + monthIndex: 2, + status: 'attention', + }, + { + id: 'appointment-m2-001', + type: 'appointment', + title: 'Retorno mensal', + description: 'Reavaliação do mês.', + date: '2026-05-22', + monthIndex: 2, + status: 'neutral', + metadata: { + consultationLocation: 'UBS Centro', + }, + }, + ], + months: [], + summary: null, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + + service = TestBed.inject(JourneyService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should start with empty state', () => { + expect(service.patient()).toEqual({ + id: '', + name: '', + leprosyType: '', + treatmentStartDate: '', + }); + expect(service.events()).toEqual([]); + expect(service.months()).toEqual([]); + expect(service.summary()).toBeNull(); + expect(service.error()).toBeNull(); + expect(service.isLoading()).toBeFalsy(); + }); + + it('should update journey data', () => { + service.updateJourneyData(mockJourneyData); + + expect(service.patient().id).toBe('patient-pb-001'); + expect(service.patient().name).toBe('José da Silva'); + expect(service.patient().leprosyType).toBe('PB'); + expect(service.patient().treatmentStartDate).toBe('2026-04-05'); + expect(service.events().length).toBe(7); + }); + + it('should expose app and treatment start dates after update', () => { + service.updateJourneyData(mockJourneyData); + + const patient = service.patient(); + + expect(patient.treatmentStartDate).toBe('2026-04-05'); + }); + + it('should expose events for april and may after update', () => { + service.updateJourneyData(mockJourneyData); + + const events = service.events(); + + expect(events.length).toBeGreaterThan(0); + expect(events.some((event) => event.date.startsWith('2026-04'))).toBeTruthy(); + expect(events.some((event) => event.date.startsWith('2026-05'))).toBeTruthy(); + }); + + it('should include appointment events', () => { + service.updateJourneyData(mockJourneyData); + + const appointments = service.events().filter((event) => event.type === 'appointment'); + + expect(appointments.length).toBe(2); + expect(appointments[0].metadata?.consultationLocation).toBeTruthy(); + }); + + it('should include month 1 medication summary', () => { + service.updateJourneyData(mockJourneyData); + + const medicationSummary = service + .events() + .find((event) => event.id === 'medication-summary-m1-001'); + + expect(medicationSummary).toBeTruthy(); + expect(medicationSummary?.type).toBe('medication-summary'); + expect(medicationSummary?.metadata?.dosesTaken).toBe(28); + expect(medicationSummary?.metadata?.dosesExpected).toBe(30); + }); + + it('should include worsening and improvement clinical updates', () => { + service.updateJourneyData(mockJourneyData); + + const worsenedEvent = service + .events() + .find((event) => event.metadata?.symptomTrend === 'worsened'); + + const improvedEvent = service + .events() + .find((event) => event.metadata?.symptomTrend === 'improved'); + + expect(worsenedEvent).toBeTruthy(); + expect(improvedEvent).toBeTruthy(); + }); + + it('should include support and alert messages', () => { + service.updateJourneyData(mockJourneyData); + + const motivationalMessages = service + .events() + .filter((event) => event.type === 'motivational-message'); + + expect(motivationalMessages.length).toBe(2); + expect(motivationalMessages.some((event) => event.status === 'attention')).toBeTruthy(); + expect(motivationalMessages.some((event) => event.status === 'positive')).toBeTruthy(); + }); + + it('should replace existing journey data when updated again', () => { + service.updateJourneyData(mockJourneyData); + + service.updateJourneyData({ + patient: { + id: 'patient-mb-002', + name: 'Maria Oliveira', + leprosyType: 'MB', + treatmentStartDate: '2026-05-02', + }, + events: [], + months: [], + summary: null, + }); + + expect(service.patient().id).toBe('patient-mb-002'); + expect(service.patient().name).toBe('Maria Oliveira'); + expect(service.events()).toEqual([]); + }); + + it('should reset state', () => { + service.updateJourneyData(mockJourneyData); + + service.resetState(); + + expect(service.patient()).toEqual({ + id: '', + name: '', + leprosyType: '', + treatmentStartDate: '', + }); + expect(service.events()).toEqual([]); + expect(service.months()).toEqual([]); + expect(service.summary()).toBeNull(); + expect(service.error()).toBeNull(); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/features/journey/services/journey-service.ts b/frontend/src/app/features/journey/services/journey-service.ts new file mode 100644 index 0000000..02c58a1 --- /dev/null +++ b/frontend/src/app/features/journey/services/journey-service.ts @@ -0,0 +1,184 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { JourneyEvent, JourneyEventStatus, JourneyEventType, LeprosyType } from '../journey'; +import { environment } from '../../../../environments/environment'; + +export interface JourneyPatient { + id: string; + name: string; + leprosyType: LeprosyType; + treatmentStartDate: string; +} + +export interface JourneyData { + patient: JourneyPatient; + events: JourneyEvent[]; + months: JourneyApiMonth[]; + summary: JourneyApiSummary | null; +} + +export interface JourneyApiSummary { + patient_id: string; + user_id: string; + display_name: string | null; + classification: LeprosyType | null; + diagnosis_date: string | null; + treatment_start_date: string | null; + estimated_end_date: string | null; + treatment_status: string | null; + treatment_duration_months: number; + total_days: number; + elapsed_days: number; + remaining_days: number; + progress_percent: number; + current_month: number; +} + +export interface JourneyApiMedicationSummary { + doses_taken: number; + doses_expected: number; + adherence_percent: number; +} + +export interface JourneyApiEvent { + id: string; + type: JourneyEventType; + title: string; + description: string; + date: string; + status: JourneyEventStatus; + metadata?: { + dosesTaken?: number; + dosesExpected?: number; + adherencePercent?: number; + symptomTrend?: 'improved' | 'stable' | 'worsened'; + consultationLocation?: string; + location?: string; + professional?: string | null; + appointment_type?: string; + performed?: boolean; + follow_up?: Record | null; + }; +} + +export interface JourneyApiMonth { + month_index: number; + label: string; + start_date: string; + end_date: string; + status: 'completed' | 'current' | 'upcoming'; + medication_summary: JourneyApiMedicationSummary; + events: JourneyApiEvent[]; +} + +export interface JourneyApiResponse { + summary: JourneyApiSummary; + months: JourneyApiMonth[]; +} + +@Injectable({ + providedIn: 'root', +}) +export class JourneyService { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + private readonly journeyDataState = signal(this.buildEmptyJourneyData()); + private readonly loadingState = signal(false); + private readonly errorState = signal(null); + + readonly journeyData = computed(() => this.journeyDataState()); + readonly patient = computed(() => this.journeyDataState().patient); + readonly events = computed(() => this.journeyDataState().events); + readonly months = computed(() => this.journeyDataState().months); + readonly summary = computed(() => this.journeyDataState().summary); + readonly isLoading = computed(() => this.loadingState()); + readonly error = computed(() => this.errorState()); + + loadJourney(): void { + console.log('loadJourney chamado'); + this.loadingState.set(true); + this.errorState.set(null); + + this.http.get(`${this.apiUrl}/v1/patients/me/journey`).subscribe({ + next: (response) => { + console.log('Resposta da API:', response); + this.journeyDataState.set(this.mapApiResponse(response)); + this.loadingState.set(false); + }, + error: (err) => { + console.error('Erro na API:', err); + this.loadingState.set(false); + this.errorState.set('Não foi possível carregar a jornada neste momento.'); + }, + }); + } + + updateJourneyData(data: JourneyData): void { + this.journeyDataState.set(data); + } + + resetState(): void { + this.journeyDataState.set(this.buildEmptyJourneyData()); + this.errorState.set(null); + } + + private mapApiResponse(response: JourneyApiResponse): JourneyData { + console.log('Entrou no mapApiResponse'); + console.log('Response:', response); + const patient: JourneyPatient = { + id: response.summary.patient_id, + name: response.summary.display_name?.trim() || 'Paciente', + leprosyType: response.summary.classification ?? 'PB', + treatmentStartDate: response.summary.treatment_start_date ?? '', + }; + + const events = response.months + .flatMap((month) => + month.events.map((event) => this.mapEvent(event, month.month_index)) + ) + .sort((a, b) => +new Date(b.date) - +new Date(a.date)); + + console.log("PATIENT AND EVENTS: ", patient, events); + + return { + patient, + events, + months: response.months, + summary: response.summary, + }; + } + + private mapEvent(event: JourneyApiEvent, monthIndex: number): JourneyEvent { + return { + id: event.id, + type: event.type, + title: event.title, + description: event.description, + date: event.date, + monthIndex, + status: event.status, + metadata: { + dosesTaken: event.metadata?.dosesTaken, + dosesExpected: event.metadata?.dosesExpected, + symptomTrend: event.metadata?.symptomTrend, + consultationLocation: + event.metadata?.consultationLocation ?? event.metadata?.location, + }, + }; + } + + private buildEmptyJourneyData(): JourneyData { + return { + patient: { + id: '', + name: '', + leprosyType: '', + treatmentStartDate: '', + }, + events: [], + months: [], + summary: null, + }; + } +} \ No newline at end of file diff --git a/frontend/src/app/features/login/login.spec.ts b/frontend/src/app/features/login/login.spec.ts index 7f7f1dd..14017ec 100644 --- a/frontend/src/app/features/login/login.spec.ts +++ b/frontend/src/app/features/login/login.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; import { of, throwError } from 'rxjs'; -import { vi, describe, beforeEach, it, expect } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Login } from './login'; import { AuthService } from '../auth/services/auth-service'; @@ -25,6 +25,12 @@ describe('Login', () => { navigateByUrl: vi.fn(), }; + const toastServiceMock = { + success: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + }; + const activatedRouteMock = { snapshot: { queryParamMap: convertToParamMap({}), @@ -33,8 +39,14 @@ describe('Login', () => { beforeEach(async () => { authServiceMock.login.mockReset(); + routerMock.navigateByUrl.mockReset(); routerMock.navigateByUrl.mockResolvedValue(true); + + toastServiceMock.success.mockReset(); + toastServiceMock.warning.mockReset(); + toastServiceMock.error.mockReset(); + activatedRouteMock.snapshot.queryParamMap = convertToParamMap({}); await TestBed.configureTestingModule({ @@ -56,6 +68,22 @@ describe('Login', () => { expect(component).toBeTruthy(); }); + it('should show success toast when registered=true', () => { + activatedRouteMock.snapshot.queryParamMap = convertToParamMap({ + registered: 'true', + }); + + fixture = TestBed.createComponent(Login); + component = fixture.componentInstance; + + fixture.detectChanges(); + + expect(toastServiceMock.success).toHaveBeenCalledWith( + 'Cadastro realizado com sucesso.', + 'Agora faça login para continuar.' + ); + }); + it('should not submit when form is invalid', () => { component.form.setValue({ identifier: '', @@ -65,7 +93,12 @@ describe('Login', () => { component.submit(); expect(authServiceMock.login).not.toHaveBeenCalled(); - expect(component.form.touched).toBe(true); + + expect(toastServiceMock.warning).toHaveBeenCalledWith( + 'Formulário inválido', + 'Preencha e-mail e senha corretamente.' + ); + expect(component.isSubmitting).toBe(false); }); @@ -133,6 +166,10 @@ describe('Login', () => { component.submit(); + expect(toastServiceMock.success).toHaveBeenCalledWith( + 'Login realizado com sucesso.' + ); + expect(routerMock.navigateByUrl).toHaveBeenCalledWith('/checkin'); expect(component.isSubmitting).toBe(false); }); @@ -215,12 +252,15 @@ describe('Login', () => { expect(toastServiceMock.error).toHaveBeenCalledWith('Falha no login', 'Credenciais inválidas.'); expect(component.isSubmitting).toBe(false); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); }); it('should show default error message when API does not return message', () => { authServiceMock.login.mockReturnValue( - throwError(() => ({ error: {} })) + throwError(() => ({ + error: {}, + })) ); component.form.setValue({ diff --git a/frontend/src/app/features/medication/medication.html b/frontend/src/app/features/medication/medication.html index ca29974..878e810 100644 --- a/frontend/src/app/features/medication/medication.html +++ b/frontend/src/app/features/medication/medication.html @@ -218,4 +218,4 @@