From 0a4ccefe283c328f7ee3e7a523706b696bb22476 Mon Sep 17 00:00:00 2001 From: Piotr Oleszczyk Date: Thu, 5 Mar 2026 12:46:49 +0100 Subject: [PATCH] feat(repo): expand lab results workflows across backend and frontend --- backend/innercontext/api/health.py | 89 +- backend/tests/test_health.py | 179 +++- docs/frontend-design-cookbook.md | 15 + frontend/messages/en.json | 43 + frontend/messages/pl.json | 43 + frontend/src/app.css | 149 ++++ frontend/src/lib/api.ts | 19 +- frontend/src/routes/+layout.svelte | 6 +- .../routes/health/lab-results/+page.server.ts | 129 ++- .../routes/health/lab-results/+page.svelte | 780 +++++++++++++++--- .../routes/health/medications/+page.svelte | 2 - .../src/routes/products/[id]/+page.svelte | 2 +- frontend/src/routes/products/new/+page.svelte | 2 +- .../src/routes/products/suggest/+page.svelte | 5 +- frontend/src/routes/routines/+page.svelte | 3 +- .../src/routes/routines/[id]/+page.svelte | 2 +- .../routines/grooming-schedule/+page.svelte | 18 +- frontend/src/routes/routines/new/+page.svelte | 2 +- .../src/routes/routines/suggest/+page.svelte | 12 +- 19 files changed, 1330 insertions(+), 170 deletions(-) diff --git a/backend/innercontext/api/health.py b/backend/innercontext/api/health.py index b0da062..86e67af 100644 --- a/backend/innercontext/api/health.py +++ b/backend/innercontext/api/health.py @@ -3,8 +3,9 @@ from datetime import datetime from typing import Optional from uuid import UUID, uuid4 -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from pydantic import field_validator +from sqlalchemy import Integer, cast, func, or_ from sqlmodel import Session, SQLModel, col, select from db import get_session @@ -120,6 +121,13 @@ class LabResultUpdate(SQLModel): notes: Optional[str] = None +class LabResultListResponse(SQLModel): + items: list[LabResult] + total: int + limit: int + offset: int + + # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- @@ -251,27 +259,86 @@ def delete_usage(usage_id: UUID, session: Session = Depends(get_session)): # --------------------------------------------------------------------------- -@router.get("/lab-results", response_model=list[LabResult]) +@router.get("/lab-results", response_model=LabResultListResponse) def list_lab_results( + q: Optional[str] = None, test_code: Optional[str] = None, flag: Optional[ResultFlag] = None, - lab: Optional[str] = None, + flags: list[ResultFlag] = Query(default_factory=list), from_date: Optional[datetime] = None, to_date: Optional[datetime] = None, + latest_only: bool = False, + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), session: Session = Depends(get_session), ): - stmt = select(LabResult) + filters = [] + if q is not None and q.strip(): + query = f"%{q.strip()}%" + filters.append( + or_( + col(LabResult.test_code).ilike(query), + col(LabResult.test_name_original).ilike(query), + ) + ) if test_code is not None: - stmt = stmt.where(LabResult.test_code == test_code) + filters.append(LabResult.test_code == test_code) if flag is not None: - stmt = stmt.where(LabResult.flag == flag) - if lab is not None: - stmt = stmt.where(LabResult.lab == lab) + filters.append(LabResult.flag == flag) + if flags: + filters.append(col(LabResult.flag).in_(flags)) if from_date is not None: - stmt = stmt.where(LabResult.collected_at >= from_date) + filters.append(LabResult.collected_at >= from_date) if to_date is not None: - stmt = stmt.where(LabResult.collected_at <= to_date) - return session.exec(stmt).all() + filters.append(LabResult.collected_at <= to_date) + + if latest_only: + ranked_stmt = select( + col(LabResult.record_id).label("record_id"), + func.row_number() + .over( + partition_by=LabResult.test_code, + order_by=( + col(LabResult.collected_at).desc(), + col(LabResult.created_at).desc(), + col(LabResult.record_id).desc(), + ), + ) + .label("rank"), + ) + if filters: + ranked_stmt = ranked_stmt.where(*filters) + + ranked_subquery = ranked_stmt.subquery() + latest_ids = select(ranked_subquery.c.record_id).where( + ranked_subquery.c.rank == 1 + ) + stmt = select(LabResult).where(col(LabResult.record_id).in_(latest_ids)) + count_stmt = select(func.count()).select_from( + select(LabResult.record_id) + .where(col(LabResult.record_id).in_(latest_ids)) + .subquery() + ) + else: + stmt = select(LabResult) + count_stmt = select(func.count()).select_from(LabResult) + if filters: + stmt = stmt.where(*filters) + count_stmt = count_stmt.where(*filters) + + test_code_numeric = cast( + func.replace(col(LabResult.test_code), "-", ""), + Integer, + ) + stmt = stmt.order_by( + col(LabResult.collected_at).desc(), + test_code_numeric.asc(), + col(LabResult.record_id).asc(), + ) + + total = session.exec(count_stmt).one() + items = list(session.exec(stmt.offset(offset).limit(limit)).all()) + return LabResultListResponse(items=items, total=total, limit=limit, offset=offset) @router.post("/lab-results", response_model=LabResult, status_code=201) diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 29a5331..efb9474 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -224,7 +224,11 @@ def test_create_lab_result_invalid_flag(client): def test_list_lab_results_empty(client): r = client.get("/health/lab-results") assert r.status_code == 200 - assert r.json() == [] + data = r.json() + assert data["items"] == [] + assert data["total"] == 0 + assert data["limit"] == 50 + assert data["offset"] == 0 def test_list_filter_test_code(client): @@ -232,9 +236,9 @@ def test_list_filter_test_code(client): client.post("/health/lab-results", json={**LAB_RESULT_DATA, "test_code": "2951-2"}) r = client.get("/health/lab-results?test_code=718-7") assert r.status_code == 200 - data = r.json() - assert len(data) == 1 - assert data[0]["test_code"] == "718-7" + items = r.json()["items"] + assert len(items) == 1 + assert items[0]["test_code"] == "718-7" def test_list_filter_flag(client): @@ -242,9 +246,9 @@ def test_list_filter_flag(client): client.post("/health/lab-results", json={**LAB_RESULT_DATA, "flag": "H"}) r = client.get("/health/lab-results?flag=H") assert r.status_code == 200 - data = r.json() - assert len(data) == 1 - assert data[0]["flag"] == "H" + items = r.json()["items"] + assert len(items) == 1 + assert items[0]["flag"] == "H" def test_list_filter_date_range(client): @@ -258,8 +262,139 @@ def test_list_filter_date_range(client): ) r = client.get("/health/lab-results?from_date=2026-05-01T00:00:00") assert r.status_code == 200 + assert len(r.json()["items"]) == 1 + + +def test_list_lab_results_search_and_pagination(client): + client.post( + "/health/lab-results", + json={ + **LAB_RESULT_DATA, + "test_code": "718-7", + "test_name_original": "Hemoglobin", + }, + ) + client.post( + "/health/lab-results", + json={ + **LAB_RESULT_DATA, + "test_code": "4548-4", + "test_name_original": "Hemoglobin A1c", + }, + ) + client.post( + "/health/lab-results", + json={**LAB_RESULT_DATA, "test_code": "2951-2", "test_name_original": "Sodium"}, + ) + + r = client.get("/health/lab-results?q=hemo&limit=1&offset=1") + assert r.status_code == 200 data = r.json() - assert len(data) == 1 + assert data["total"] == 2 + assert data["limit"] == 1 + assert data["offset"] == 1 + assert len(data["items"]) == 1 + assert "Hemoglobin" in data["items"][0]["test_name_original"] + + +def test_list_lab_results_sorted_newest_first(client): + client.post( + "/health/lab-results", + json={ + **LAB_RESULT_DATA, + "collected_at": "2026-01-01T00:00:00", + "test_code": "1111-1", + }, + ) + client.post( + "/health/lab-results", + json={ + **LAB_RESULT_DATA, + "collected_at": "2026-06-01T00:00:00", + "test_code": "2222-2", + }, + ) + r = client.get("/health/lab-results") + assert r.status_code == 200 + items = r.json()["items"] + assert items[0]["collected_at"] == "2026-06-01T00:00:00" + + +def test_list_lab_results_test_code_sorted_numerically_for_same_date(client): + client.post( + "/health/lab-results", + json={ + **LAB_RESULT_DATA, + "collected_at": "2026-06-01T00:00:00", + "test_code": "1111-1", + }, + ) + client.post( + "/health/lab-results", + json={ + **LAB_RESULT_DATA, + "collected_at": "2026-06-01T00:00:00", + "test_code": "99-9", + }, + ) + client.post( + "/health/lab-results", + json={ + **LAB_RESULT_DATA, + "collected_at": "2026-06-01T00:00:00", + "test_code": "718-7", + }, + ) + + r = client.get("/health/lab-results") + assert r.status_code == 200 + items = r.json()["items"] + assert [items[0]["test_code"], items[1]["test_code"], items[2]["test_code"]] == [ + "99-9", + "718-7", + "1111-1", + ] + + +def test_list_lab_results_latest_only_returns_one_per_test_code(client): + client.post( + "/health/lab-results", + json={ + **LAB_RESULT_DATA, + "test_code": "1742-6", + "test_name_original": "ALT", + "collected_at": "2026-01-01T00:00:00", + "value_num": 30, + }, + ) + client.post( + "/health/lab-results", + json={ + **LAB_RESULT_DATA, + "test_code": "1742-6", + "test_name_original": "ALT", + "collected_at": "2026-02-01T00:00:00", + "value_num": 60, + }, + ) + client.post( + "/health/lab-results", + json={ + **LAB_RESULT_DATA, + "test_code": "2093-3", + "test_name_original": "Cholesterol total", + "collected_at": "2026-01-10T00:00:00", + "value_num": 200, + }, + ) + + r = client.get("/health/lab-results?latest_only=true") + assert r.status_code == 200 + data = r.json() + assert data["total"] == 2 + assert len(data["items"]) == 2 + alt = next(item for item in data["items"] if item["test_code"] == "1742-6") + assert alt["collected_at"] == "2026-02-01T00:00:00" def test_get_lab_result(client): @@ -285,6 +420,34 @@ def test_update_lab_result(client): assert r2.json()["notes"] == "Recheck in 3 months" +def test_update_lab_result_can_clear_and_switch_value_type(client): + r = client.post( + "/health/lab-results", + json={ + **LAB_RESULT_DATA, + "value_num": 12.5, + "unit_original": "mg/dl", + "flag": "H", + }, + ) + rid = r.json()["record_id"] + r2 = client.patch( + f"/health/lab-results/{rid}", + json={ + "value_num": None, + "value_text": "not detected", + "flag": None, + "unit_original": None, + }, + ) + assert r2.status_code == 200 + data = r2.json() + assert data["value_num"] is None + assert data["value_text"] == "not detected" + assert data["flag"] is None + assert data["unit_original"] is None + + def test_delete_lab_result(client): r = client.post("/health/lab-results", json=LAB_RESULT_DATA) rid = r.json()["record_id"] diff --git a/docs/frontend-design-cookbook.md b/docs/frontend-design-cookbook.md index 394cc39..2ee9e0f 100644 --- a/docs/frontend-design-cookbook.md +++ b/docs/frontend-design-cookbook.md @@ -97,12 +97,27 @@ These classes are already in use and should be reused: - Table shell: `products-table-shell` - Tabs shell: `products-tabs`, `editorial-tabs` - Health semantic pills: `health-kind-pill*`, `health-flag-pill*` +- Lab results utilities: + - metadata chips: `lab-results-meta-strip`, `lab-results-meta-pill` + - filter surfaces: `lab-results-filter-panel`, `lab-results-filter-banner`, `lab-results-pager` + - row/link rhythm: `lab-results-row`, `lab-results-code-link`, `lab-results-value-cell` + - mobile density: `lab-results-mobile-grid`, `lab-results-mobile-card`, `lab-results-mobile-value` ## Forms and data views - Inputs should remain high-contrast and calm. - Validation/error states should be explicit and never color-only. - Tables and dense lists should prioritize scanning: spacing, row separators, concise metadata. +- Filter toolbars for data-heavy routes should use `GET` forms with URL params so state is shareable and pagination links preserve active filters. +- For high-volume medical data lists, default the primary view to condensed/latest mode and offer full-history as an explicit secondary option. +- In condensed/latest mode, group rows by collection date using lightweight section headers (`products-section-title`) to preserve report context without introducing heavy card nesting. +- Change/highlight pills in dense tables should stay compact (`text-[10px]`), semantic (new/flag change/abnormal), and avoid overwhelming color blocks. +- For lab results, keep ordering fixed to newest collection date (`collected_at DESC`) and remove non-essential controls (no lab filter and no manual sort selector). +- For lab results, keep code links visibly interactive (`lab-results-code-link`) because they are a primary in-context drill-down interaction. +- For lab results, use compact metadata chips in hero sections (`lab-results-meta-pill`) for active view/filter context instead of introducing a second heavy summary card. +- In dense row-based lists, prefer `ghost` action controls; use icon-only buttons on desktop tables and short text+icon `ghost` actions on mobile cards to keep row actions subordinate to data. +- For editable data tables, open a dedicated inline edit panel above the list (instead of per-row expanded forms) and prefill it from row actions; keep users on the same filtered/paginated context after save. +- When a list is narrowed to a single entity key (for example `test_code`), display an explicit "filtered by" banner with a one-click clear action and avoid extra grouping wrappers that add no context. ### DRY form primitives diff --git a/frontend/messages/en.json b/frontend/messages/en.json index e1369fb..8ee99f5 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -280,12 +280,55 @@ "labResults_unitPlaceholder": "e.g. g/dL", "labResults_flag": "Flag", "labResults_added": "Result added.", + "labResults_deleted": "Result deleted.", + "labResults_updated": "Result updated.", + "labResults_editTitle": "Edit result", + "labResults_confirmDelete": "Delete this result?", + "labResults_search": "Search", + "labResults_searchPlaceholder": "test name or code", + "labResults_from": "From", + "labResults_to": "To", + "labResults_sort": "Sort", + "labResults_sortNewest": "Newest first", + "labResults_sortOldest": "Oldest first", + "labResults_applyFilters": "Apply filters", + "labResults_resetFilters": "Reset", + "labResults_resetAllFilters": "Reset all", + "labResults_filteredByCode": "Filtered by test code: {code}", + "labResults_clearCodeFilter": "Clear code filter", + "labResults_previous": "Previous", + "labResults_next": "Next", + "labResults_view": "View", + "labResults_viewLatest": "Latest per test", + "labResults_viewAll": "Full history", + "labResults_loincName": "LOINC name", + "labResults_valueType": "Value type", + "labResults_valueTypeNumeric": "Numeric", + "labResults_valueTypeText": "Text", + "labResults_valueTypeBoolean": "Boolean", + "labResults_valueTypeEmpty": "Empty", + "labResults_valueEmpty": "No value", + "labResults_boolTrue": "True", + "labResults_boolFalse": "False", + "labResults_advanced": "Advanced fields", + "labResults_unitUcum": "UCUM unit", + "labResults_refLow": "Reference low", + "labResults_refHigh": "Reference high", + "labResults_refText": "Reference text", + "labResults_sourceFile": "Source file", + "labResults_notes": "Notes", + "labResults_changeNew": "new marker", + "labResults_changeBecameAbnormal": "became abnormal", + "labResults_changeFlagChanged": "flag changed", + "labResults_changeDelta": "Δ {delta}", + "labResults_pageIndicator": "Page {page} / {total}", "labResults_colDate": "Date", "labResults_colTest": "Test", "labResults_colLoinc": "LOINC", "labResults_colValue": "Value", "labResults_colFlag": "Flag", "labResults_colLab": "Lab", + "labResults_colActions": "Actions", "labResults_noResults": "No lab results found.", "skin_title": "Skin Snapshots", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 6fc1d44..9e45d4b 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -292,12 +292,55 @@ "labResults_unitPlaceholder": "np. g/dL", "labResults_flag": "Flaga", "labResults_added": "Wynik dodany.", + "labResults_deleted": "Wynik usunięty.", + "labResults_updated": "Wynik zaktualizowany.", + "labResults_editTitle": "Edytuj wynik", + "labResults_confirmDelete": "Usunąć ten wynik?", + "labResults_search": "Szukaj", + "labResults_searchPlaceholder": "nazwa badania lub kod", + "labResults_from": "Od", + "labResults_to": "Do", + "labResults_sort": "Sortowanie", + "labResults_sortNewest": "Najnowsze najpierw", + "labResults_sortOldest": "Najstarsze najpierw", + "labResults_applyFilters": "Zastosuj filtry", + "labResults_resetFilters": "Reset", + "labResults_resetAllFilters": "Wyczyść wszystko", + "labResults_filteredByCode": "Filtrowanie po kodzie badania: {code}", + "labResults_clearCodeFilter": "Wyczyść filtr kodu", + "labResults_previous": "Poprzednia", + "labResults_next": "Następna", + "labResults_view": "Widok", + "labResults_viewLatest": "Najnowszy wynik na badanie", + "labResults_viewAll": "Pełna historia", + "labResults_loincName": "Nazwa LOINC", + "labResults_valueType": "Typ wartości", + "labResults_valueTypeNumeric": "Liczba", + "labResults_valueTypeText": "Tekst", + "labResults_valueTypeBoolean": "Boolean", + "labResults_valueTypeEmpty": "Puste", + "labResults_valueEmpty": "Brak wartości", + "labResults_boolTrue": "Prawda", + "labResults_boolFalse": "Fałsz", + "labResults_advanced": "Pola zaawansowane", + "labResults_unitUcum": "Jednostka UCUM", + "labResults_refLow": "Dolna norma", + "labResults_refHigh": "Górna norma", + "labResults_refText": "Opis normy", + "labResults_sourceFile": "Plik źródłowy", + "labResults_notes": "Notatki", + "labResults_changeNew": "nowy marker", + "labResults_changeBecameAbnormal": "poza normą", + "labResults_changeFlagChanged": "zmiana flagi", + "labResults_changeDelta": "Δ {delta}", + "labResults_pageIndicator": "Strona {page} / {total}", "labResults_colDate": "Data", "labResults_colTest": "Badanie", "labResults_colLoinc": "LOINC", "labResults_colValue": "Wartość", "labResults_colFlag": "Flaga", "labResults_colLab": "Lab", + "labResults_colActions": "Akcje", "labResults_noResults": "Nie znaleziono wyników badań.", "skin_title": "Stan skóry", diff --git a/frontend/src/app.css b/frontend/src/app.css index eb5a491..e602017 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -240,6 +240,8 @@ body { display: flex; flex-wrap: wrap; gap: 0.5rem; + width: 100%; + justify-content: flex-start; } .editorial-filter-row { @@ -389,6 +391,108 @@ body { color: hsl(28 55% 30%); } +.lab-results-meta-strip { + margin-top: 0.9rem; + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.lab-results-meta-pill { + border: 1px solid hsl(36 22% 74% / 0.9); + border-radius: 999px; + background: hsl(44 32% 93%); + padding: 0.2rem 0.62rem; + color: var(--editorial-muted); + font-size: 0.73rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.lab-results-meta-pill--alert { + border-color: hsl(12 56% 69%); + background: hsl(10 66% 90%); + color: hsl(10 63% 30%); + min-width: 2.25rem; + justify-content: center; +} + +.lab-results-filter-panel { + border-color: color-mix(in srgb, var(--page-accent) 24%, var(--border)); + background: + linear-gradient(180deg, hsl(44 36% 96%), hsl(42 29% 93%)), + repeating-linear-gradient(90deg, hsl(0 0% 100% / 0), hsl(0 0% 100% / 0) 18px, hsl(36 24% 70% / 0.1) 18px, hsl(36 24% 70% / 0.1) 19px); +} + +.lab-results-filter-banner { + border-style: dashed; + border-color: color-mix(in srgb, var(--page-accent) 48%, var(--border)); + background: color-mix(in srgb, var(--page-accent-soft) 52%, white); +} + +.lab-results-pager { + border-color: color-mix(in srgb, var(--page-accent) 26%, var(--border)); +} + +.lab-results-table table { + border-collapse: separate; + border-spacing: 0; +} + +.lab-results-row td { + vertical-align: middle; +} + +.lab-results-row-actions { + opacity: 0.62; + transition: opacity 120ms ease; +} + +.lab-results-row:hover .lab-results-row-actions, +.lab-results-row:focus-within .lab-results-row-actions { + opacity: 1; +} + +.lab-results-code-link { + border-radius: 0.32rem; + text-decoration: none; + transition: color 120ms ease, background-color 120ms ease; +} + +.lab-results-code-link:hover { + color: var(--page-accent); + text-decoration: underline; + text-underline-offset: 3px; +} + +.lab-results-code-link:focus-visible { + outline: 2px solid var(--page-accent); + outline-offset: 2px; +} + +.lab-results-value-cell { + font-variant-numeric: tabular-nums; + font-feature-settings: 'tnum'; +} + +.lab-results-mobile-grid .products-section-title { + margin-top: 0.15rem; +} + +.lab-results-mobile-card { + gap: 0.45rem; + background: linear-gradient(170deg, hsl(44 34% 96%), hsl(42 30% 94%)); +} + +.lab-results-mobile-value { + justify-content: space-between; +} + +.lab-results-mobile-actions { + margin-top: 0.1rem; +} + [data-slot='card'] { border-color: hsl(35 22% 75% / 0.8); background: linear-gradient(170deg, hsl(44 34% 97%), hsl(41 30% 95%)); @@ -416,6 +520,37 @@ body { .app-main { padding: 2rem; } + + .editorial-hero { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + 'kicker actions' + 'title actions' + 'subtitle actions'; + column-gap: 1rem; + align-items: start; + } + + .editorial-kicker { + grid-area: kicker; + } + + .editorial-title { + grid-area: title; + } + + .editorial-subtitle { + grid-area: subtitle; + } + + .editorial-toolbar { + grid-area: actions; + margin-top: 0; + width: auto; + justify-content: flex-end; + align-self: start; + } } .editorial-dashboard { @@ -487,6 +622,7 @@ body { } .hero-strip { + grid-column: 1 / -1; margin-top: 1.3rem; display: flex; flex-wrap: wrap; @@ -807,6 +943,19 @@ body { .routine-pill { letter-spacing: 0.08em; } + + .lab-results-meta-strip { + margin-top: 0.75rem; + } + + .lab-results-meta-pill { + letter-spacing: 0.06em; + } + + .lab-results-filter-banner { + align-items: flex-start; + flex-direction: column; + } } @media (prefers-reduced-motion: reduce) { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c046723..193fb81 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -264,22 +264,35 @@ export const createMedicationUsage = ( // ─── Health – Lab results ──────────────────────────────────────────────────── export interface LabResultListParams { + q?: string; test_code?: string; flag?: string; - lab?: string; from_date?: string; to_date?: string; + latest_only?: boolean; + limit?: number; + offset?: number; +} + +export interface LabResultListResponse { + items: LabResult[]; + total: number; + limit: number; + offset: number; } export function getLabResults( params: LabResultListParams = {}, -): Promise { +): Promise { const q = new URLSearchParams(); + if (params.q) q.set("q", params.q); if (params.test_code) q.set("test_code", params.test_code); if (params.flag) q.set("flag", params.flag); - if (params.lab) q.set("lab", params.lab); if (params.from_date) q.set("from_date", params.from_date); if (params.to_date) q.set("to_date", params.to_date); + if (params.latest_only != null) q.set("latest_only", String(params.latest_only)); + if (params.limit != null) q.set("limit", String(params.limit)); + if (params.offset != null) q.set("offset", String(params.offset)); const qs = q.toString(); return api.get(`/health/lab-results${qs ? `?${qs}` : ""}`); } diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 43560ca..7463efa 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -23,12 +23,12 @@ const navItems = $derived([ { href: resolve('/'), label: m.nav_dashboard(), icon: House }, - { href: resolve('/products'), label: m.nav_products(), icon: Package }, { href: resolve('/routines'), label: m.nav_routines(), icon: ClipboardList }, { href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: Scissors }, + { href: resolve('/products'), label: m.nav_products(), icon: Package }, + { href: resolve('/skin'), label: m.nav_skin(), icon: Sparkles }, { href: resolve('/health/medications'), label: m.nav_medications(), icon: Pill }, - { href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: FlaskConical }, - { href: resolve('/skin'), label: m.nav_skin(), icon: Sparkles } + { href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: FlaskConical } ]); function isActive(href: string) { diff --git a/frontend/src/routes/health/lab-results/+page.server.ts b/frontend/src/routes/health/lab-results/+page.server.ts index 167f773..c38458f 100644 --- a/frontend/src/routes/health/lab-results/+page.server.ts +++ b/frontend/src/routes/health/lab-results/+page.server.ts @@ -1,12 +1,43 @@ -import { createLabResult, getLabResults } from '$lib/api'; +import { createLabResult, deleteLabResult, getLabResults, updateLabResult } from '$lib/api'; import { fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ url }) => { + const q = url.searchParams.get('q') ?? undefined; + const test_code = url.searchParams.get('test_code') ?? undefined; const flag = url.searchParams.get('flag') ?? undefined; const from_date = url.searchParams.get('from_date') ?? undefined; - const results = await getLabResults({ flag, from_date }); - return { results, flag }; + const to_date = url.searchParams.get('to_date') ?? undefined; + const requestedLatestOnly = url.searchParams.get('latest_only') !== 'false'; + const latestOnly = test_code ? false : requestedLatestOnly; + const pageRaw = Number(url.searchParams.get('page') ?? '1'); + const page = Number.isFinite(pageRaw) && pageRaw > 0 ? Math.floor(pageRaw) : 1; + const limit = 50; + const offset = (page - 1) * limit; + + const resultPage = await getLabResults({ + q, + test_code, + flag, + from_date, + to_date, + latest_only: latestOnly, + limit, + offset + }); + const totalPages = Math.max(1, Math.ceil(resultPage.total / limit)); + + return { + resultPage, + q, + test_code, + flag, + from_date, + to_date, + latestOnly, + page, + totalPages + }; }; export const actions: Actions = { @@ -40,5 +71,97 @@ export const actions: Actions = { } catch (e) { return fail(500, { error: (e as Error).message }); } + }, + + update: async ({ request }) => { + const form = await request.formData(); + const id = form.get('id') as string; + const collected_at = form.get('collected_at') as string; + const test_code = form.get('test_code') as string; + const test_name_original = form.get('test_name_original') as string; + const test_name_loinc = form.get('test_name_loinc') as string; + const value_mode = form.get('value_mode') as string; + const value_num = form.get('value_num') as string; + const value_text = form.get('value_text') as string; + const value_bool = form.get('value_bool') as string; + const unit_original = form.get('unit_original') as string; + const unit_ucum = form.get('unit_ucum') as string; + const ref_low = form.get('ref_low') as string; + const ref_high = form.get('ref_high') as string; + const ref_text = form.get('ref_text') as string; + const flag = form.get('flag') as string; + const lab = form.get('lab') as string; + const source_file = form.get('source_file') as string; + const notes = form.get('notes') as string; + + if (!id) return fail(400, { error: 'Missing id' }); + if (!collected_at || !test_code) { + return fail(400, { error: 'Date and test code are required' }); + } + + const nullableText = (raw: string): string | null => { + const v = raw?.trim(); + return v ? v : null; + }; + const nullableNumber = (raw: string): number | null => { + const v = raw?.trim(); + if (!v) return null; + const parsed = Number(v); + return Number.isFinite(parsed) ? parsed : null; + }; + + const body: Record = { + collected_at, + test_code, + test_name_original: nullableText(test_name_original), + test_name_loinc: nullableText(test_name_loinc), + unit_original: nullableText(unit_original), + unit_ucum: nullableText(unit_ucum), + ref_low: nullableNumber(ref_low), + ref_high: nullableNumber(ref_high), + ref_text: nullableText(ref_text), + flag: nullableText(flag), + lab: nullableText(lab), + source_file: nullableText(source_file), + notes: nullableText(notes) + }; + + if (value_mode === 'num') { + body.value_num = nullableNumber(value_num); + body.value_text = null; + body.value_bool = null; + } else if (value_mode === 'text') { + body.value_num = null; + body.value_text = nullableText(value_text); + body.value_bool = null; + } else if (value_mode === 'bool') { + body.value_num = null; + body.value_text = null; + body.value_bool = value_bool === 'true' ? true : value_bool === 'false' ? false : null; + } else { + body.value_num = null; + body.value_text = null; + body.value_bool = null; + } + + try { + await updateLabResult(id, body); + return { updated: true }; + } catch (e) { + return fail(500, { error: (e as Error).message }); + } + }, + + delete: async ({ request }) => { + const form = await request.formData(); + const id = form.get('id') as string; + if (!id) return fail(400, { error: 'Missing id' }); + + try { + await deleteLabResult(id); + return { deleted: true }; + } catch (e) { + return fail(500, { error: (e as Error).message }); + } } }; diff --git a/frontend/src/routes/health/lab-results/+page.svelte b/frontend/src/routes/health/lab-results/+page.svelte index 1e58427..fb72b5a 100644 --- a/frontend/src/routes/health/lab-results/+page.svelte +++ b/frontend/src/routes/health/lab-results/+page.svelte @@ -1,7 +1,5 @@ {m["labResults_title"]()} — innercontext @@ -50,9 +180,20 @@

{m["nav_appSubtitle"]()}

{m["labResults_title"]()}

-

{m["labResults_count"]({ count: data.results.length })}

+

{m["labResults_count"]({ count: data.resultPage.total })}

+
+ + {m['labResults_view']()}: {data.latestOnly ? m['labResults_viewLatest']() : m['labResults_viewAll']()} + + {m['labResults_flagFilter']()} {data.flag || m['labResults_flagAll']()} + + {m['labResults_flag']()}: {flaggedCount} + + {#if data.test_code} + {data.test_code} + {/if} +
- @@ -65,66 +206,254 @@ {#if form?.created}
{m["labResults_added"]()}
{/if} + {#if form?.deleted} +
{m["labResults_deleted"]()}
+ {/if} + {#if form?.updated} +
{m["labResults_updated"]()}
+ {/if} - -
- {m["labResults_flagFilter"]()} - -
- - {#if showForm} - -
-
- - + {#if editingId} + + { + return async ({ result, update }) => { + await update(); + if (result.type === 'success') editingId = null; + }; + }} + class="grid grid-cols-1 sm:grid-cols-2 gap-4" + > + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {#if editValueMode === 'num'} + + + {:else if editValueMode === 'text'} + + + {:else if editValueMode === 'bool'} + + + {:else} + + + {/if} +
+
+ + +
+
+ + +
+
+ + +
+
+ {m["labResults_advanced"]()} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
- -
- +
+
+ + +
+
{/if} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {#if data.test_code} + + {/if} + + +
+
+ + {#if data.test_code} +
+

+ {m['labResults_filteredByCode']({ code: data.test_code })} +

+ +
+ {/if} + + {#if showForm} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ +
+ {/if} + + {#if data.totalPages > 1} +
+ +

+ {m["labResults_pageIndicator"]({ page: data.page, total: data.totalPages })} +

+ +
+ {/if} + -