feat(repo): expand lab results workflows across backend and frontend

This commit is contained in:
Piotr Oleszczyk 2026-03-05 12:46:49 +01:00
parent f1b104909d
commit 0a4ccefe28
19 changed files with 1330 additions and 170 deletions

View file

@ -3,8 +3,9 @@ from datetime import datetime
from typing import Optional from typing import Optional
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Query
from pydantic import field_validator from pydantic import field_validator
from sqlalchemy import Integer, cast, func, or_
from sqlmodel import Session, SQLModel, col, select from sqlmodel import Session, SQLModel, col, select
from db import get_session from db import get_session
@ -120,6 +121,13 @@ class LabResultUpdate(SQLModel):
notes: Optional[str] = None notes: Optional[str] = None
class LabResultListResponse(SQLModel):
items: list[LabResult]
total: int
limit: int
offset: int
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helper # 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( def list_lab_results(
q: Optional[str] = None,
test_code: Optional[str] = None, test_code: Optional[str] = None,
flag: Optional[ResultFlag] = None, flag: Optional[ResultFlag] = None,
lab: Optional[str] = None, flags: list[ResultFlag] = Query(default_factory=list),
from_date: Optional[datetime] = None, from_date: Optional[datetime] = None,
to_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), 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: 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: if flag is not None:
stmt = stmt.where(LabResult.flag == flag) filters.append(LabResult.flag == flag)
if lab is not None: if flags:
stmt = stmt.where(LabResult.lab == lab) filters.append(col(LabResult.flag).in_(flags))
if from_date is not None: 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: if to_date is not None:
stmt = stmt.where(LabResult.collected_at <= to_date) filters.append(LabResult.collected_at <= to_date)
return session.exec(stmt).all()
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) @router.post("/lab-results", response_model=LabResult, status_code=201)

View file

@ -224,7 +224,11 @@ def test_create_lab_result_invalid_flag(client):
def test_list_lab_results_empty(client): def test_list_lab_results_empty(client):
r = client.get("/health/lab-results") r = client.get("/health/lab-results")
assert r.status_code == 200 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): 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"}) client.post("/health/lab-results", json={**LAB_RESULT_DATA, "test_code": "2951-2"})
r = client.get("/health/lab-results?test_code=718-7") r = client.get("/health/lab-results?test_code=718-7")
assert r.status_code == 200 assert r.status_code == 200
data = r.json() items = r.json()["items"]
assert len(data) == 1 assert len(items) == 1
assert data[0]["test_code"] == "718-7" assert items[0]["test_code"] == "718-7"
def test_list_filter_flag(client): 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"}) client.post("/health/lab-results", json={**LAB_RESULT_DATA, "flag": "H"})
r = client.get("/health/lab-results?flag=H") r = client.get("/health/lab-results?flag=H")
assert r.status_code == 200 assert r.status_code == 200
data = r.json() items = r.json()["items"]
assert len(data) == 1 assert len(items) == 1
assert data[0]["flag"] == "H" assert items[0]["flag"] == "H"
def test_list_filter_date_range(client): 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") r = client.get("/health/lab-results?from_date=2026-05-01T00:00:00")
assert r.status_code == 200 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() 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): def test_get_lab_result(client):
@ -285,6 +420,34 @@ def test_update_lab_result(client):
assert r2.json()["notes"] == "Recheck in 3 months" 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): def test_delete_lab_result(client):
r = client.post("/health/lab-results", json=LAB_RESULT_DATA) r = client.post("/health/lab-results", json=LAB_RESULT_DATA)
rid = r.json()["record_id"] rid = r.json()["record_id"]

View file

@ -97,12 +97,27 @@ These classes are already in use and should be reused:
- Table shell: `products-table-shell` - Table shell: `products-table-shell`
- Tabs shell: `products-tabs`, `editorial-tabs` - Tabs shell: `products-tabs`, `editorial-tabs`
- Health semantic pills: `health-kind-pill*`, `health-flag-pill*` - 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 ## Forms and data views
- Inputs should remain high-contrast and calm. - Inputs should remain high-contrast and calm.
- Validation/error states should be explicit and never color-only. - Validation/error states should be explicit and never color-only.
- Tables and dense lists should prioritize scanning: spacing, row separators, concise metadata. - 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 ### DRY form primitives

View file

@ -280,12 +280,55 @@
"labResults_unitPlaceholder": "e.g. g/dL", "labResults_unitPlaceholder": "e.g. g/dL",
"labResults_flag": "Flag", "labResults_flag": "Flag",
"labResults_added": "Result added.", "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_colDate": "Date",
"labResults_colTest": "Test", "labResults_colTest": "Test",
"labResults_colLoinc": "LOINC", "labResults_colLoinc": "LOINC",
"labResults_colValue": "Value", "labResults_colValue": "Value",
"labResults_colFlag": "Flag", "labResults_colFlag": "Flag",
"labResults_colLab": "Lab", "labResults_colLab": "Lab",
"labResults_colActions": "Actions",
"labResults_noResults": "No lab results found.", "labResults_noResults": "No lab results found.",
"skin_title": "Skin Snapshots", "skin_title": "Skin Snapshots",

View file

@ -292,12 +292,55 @@
"labResults_unitPlaceholder": "np. g/dL", "labResults_unitPlaceholder": "np. g/dL",
"labResults_flag": "Flaga", "labResults_flag": "Flaga",
"labResults_added": "Wynik dodany.", "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_colDate": "Data",
"labResults_colTest": "Badanie", "labResults_colTest": "Badanie",
"labResults_colLoinc": "LOINC", "labResults_colLoinc": "LOINC",
"labResults_colValue": "Wartość", "labResults_colValue": "Wartość",
"labResults_colFlag": "Flaga", "labResults_colFlag": "Flaga",
"labResults_colLab": "Lab", "labResults_colLab": "Lab",
"labResults_colActions": "Akcje",
"labResults_noResults": "Nie znaleziono wyników badań.", "labResults_noResults": "Nie znaleziono wyników badań.",
"skin_title": "Stan skóry", "skin_title": "Stan skóry",

View file

@ -240,6 +240,8 @@ body {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
width: 100%;
justify-content: flex-start;
} }
.editorial-filter-row { .editorial-filter-row {
@ -389,6 +391,108 @@ body {
color: hsl(28 55% 30%); 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'] { [data-slot='card'] {
border-color: hsl(35 22% 75% / 0.8); border-color: hsl(35 22% 75% / 0.8);
background: linear-gradient(170deg, hsl(44 34% 97%), hsl(41 30% 95%)); background: linear-gradient(170deg, hsl(44 34% 97%), hsl(41 30% 95%));
@ -416,6 +520,37 @@ body {
.app-main { .app-main {
padding: 2rem; 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 { .editorial-dashboard {
@ -487,6 +622,7 @@ body {
} }
.hero-strip { .hero-strip {
grid-column: 1 / -1;
margin-top: 1.3rem; margin-top: 1.3rem;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -807,6 +943,19 @@ body {
.routine-pill { .routine-pill {
letter-spacing: 0.08em; 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) { @media (prefers-reduced-motion: reduce) {

View file

@ -264,22 +264,35 @@ export const createMedicationUsage = (
// ─── Health Lab results ──────────────────────────────────────────────────── // ─── Health Lab results ────────────────────────────────────────────────────
export interface LabResultListParams { export interface LabResultListParams {
q?: string;
test_code?: string; test_code?: string;
flag?: string; flag?: string;
lab?: string;
from_date?: string; from_date?: string;
to_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( export function getLabResults(
params: LabResultListParams = {}, params: LabResultListParams = {},
): Promise<LabResult[]> { ): Promise<LabResultListResponse> {
const q = new URLSearchParams(); 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.test_code) q.set("test_code", params.test_code);
if (params.flag) q.set("flag", params.flag); 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.from_date) q.set("from_date", params.from_date);
if (params.to_date) q.set("to_date", params.to_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(); const qs = q.toString();
return api.get(`/health/lab-results${qs ? `?${qs}` : ""}`); return api.get(`/health/lab-results${qs ? `?${qs}` : ""}`);
} }

View file

@ -23,12 +23,12 @@
const navItems = $derived([ const navItems = $derived([
{ href: resolve('/'), label: m.nav_dashboard(), icon: House }, { 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'), label: m.nav_routines(), icon: ClipboardList },
{ href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: Scissors }, { 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/medications'), label: m.nav_medications(), icon: Pill },
{ href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: FlaskConical }, { href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: FlaskConical }
{ href: resolve('/skin'), label: m.nav_skin(), icon: Sparkles }
]); ]);
function isActive(href: string) { function isActive(href: string) {

View file

@ -1,12 +1,43 @@
import { createLabResult, getLabResults } from '$lib/api'; import { createLabResult, deleteLabResult, getLabResults, updateLabResult } from '$lib/api';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => { 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 flag = url.searchParams.get('flag') ?? undefined;
const from_date = url.searchParams.get('from_date') ?? undefined; const from_date = url.searchParams.get('from_date') ?? undefined;
const results = await getLabResults({ flag, from_date }); const to_date = url.searchParams.get('to_date') ?? undefined;
return { results, flag }; 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 = { export const actions: Actions = {
@ -40,5 +71,97 @@ export const actions: Actions = {
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); 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<string, unknown> = {
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 });
}
} }
}; };

View file

@ -1,7 +1,5 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
@ -10,6 +8,7 @@
import { baseSelectClass } from '$lib/components/forms/form-classes'; import { baseSelectClass } from '$lib/components/forms/form-classes';
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte'; import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte'; import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { Pencil, X } from 'lucide-svelte';
import { import {
Table, Table,
TableBody, TableBody,
@ -33,15 +32,146 @@
let showForm = $state(false); let showForm = $state(false);
let selectedFlag = $state(''); let selectedFlag = $state('');
let filterFlag = $derived(data.flag ?? ''); let editingId = $state<string | null>(null);
let editCollectedAt = $state('');
let editTestCode = $state('');
let editTestNameOriginal = $state('');
let editTestNameLoinc = $state('');
let editValueMode = $state<'num' | 'text' | 'bool' | 'empty'>('empty');
let editValueNum = $state('');
let editValueText = $state('');
let editValueBool = $state('');
let editUnitOriginal = $state('');
let editUnitUcum = $state('');
let editRefLow = $state('');
let editRefHigh = $state('');
let editRefText = $state('');
let editFlag = $state('');
let editLab = $state('');
let editSourceFile = $state('');
let editNotes = $state('');
function startEdit(item: LabResultItem) {
editingId = item.record_id;
editCollectedAt = item.collected_at.slice(0, 10);
editTestCode = item.test_code;
editTestNameOriginal = item.test_name_original ?? '';
editTestNameLoinc = item.test_name_loinc ?? '';
if (item.value_num != null) {
editValueMode = 'num';
editValueNum = String(item.value_num);
editValueText = '';
editValueBool = '';
} else if (item.value_text != null && item.value_text !== '') {
editValueMode = 'text';
editValueNum = '';
editValueText = item.value_text;
editValueBool = '';
} else if (item.value_bool != null) {
editValueMode = 'bool';
editValueNum = '';
editValueText = '';
editValueBool = item.value_bool ? 'true' : 'false';
} else {
editValueMode = 'empty';
editValueNum = '';
editValueText = '';
editValueBool = '';
}
editUnitOriginal = item.unit_original ?? '';
editUnitUcum = item.unit_ucum ?? '';
editRefLow = item.ref_low != null ? String(item.ref_low) : '';
editRefHigh = item.ref_high != null ? String(item.ref_high) : '';
editRefText = item.ref_text ?? '';
editFlag = item.flag ?? '';
editLab = item.lab ?? '';
editSourceFile = item.source_file ?? '';
editNotes = item.notes ?? '';
}
function cancelEdit() {
editingId = null;
}
type LabResultItem = PageData['resultPage']['items'][number];
type GroupedByDate = { date: string; items: LabResultItem[] };
function buildPageUrl(page: number) {
const base = '/health/lab-results';
const params: Array<[string, string]> = [];
if (data.q) params.push(['q', data.q]);
if (data.test_code) params.push(['test_code', data.test_code]);
if (data.flag) params.push(['flag', data.flag]);
if (data.from_date) params.push(['from_date', data.from_date]);
if (data.to_date) params.push(['to_date', data.to_date]);
if (!data.latestOnly) params.push(['latest_only', 'false']);
if (page > 1) params.push(['page', String(page)]);
const qs = params
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
return qs ? `${base}?${qs}` : base;
}
function filterByCode(code: string) {
const params: Array<[string, string]> = [];
if (data.q) params.push(['q', data.q]);
params.push(['test_code', code]);
if (data.flag) params.push(['flag', data.flag]);
if (data.from_date) params.push(['from_date', data.from_date]);
if (data.to_date) params.push(['to_date', data.to_date]);
params.push(['latest_only', 'false']);
const qs = params
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
window.location.href = qs ? `/health/lab-results?${qs}` : '/health/lab-results';
}
function clearCodeFilterOnly() {
const params: Array<[string, string]> = [];
if (data.q) params.push(['q', data.q]);
if (data.flag) params.push(['flag', data.flag]);
if (data.from_date) params.push(['from_date', data.from_date]);
if (data.to_date) params.push(['to_date', data.to_date]);
if (!data.latestOnly) params.push(['latest_only', 'false']);
const qs = params
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
window.location.href = qs ? `/health/lab-results?${qs}` : '/health/lab-results';
}
const groupedByDate = $derived.by<GroupedByDate[]>(() => {
const groups: Record<string, LabResultItem[]> = {};
for (const item of data.resultPage.items) {
const date = item.collected_at.slice(0, 10);
if (groups[date]) {
groups[date].push(item);
} else {
groups[date] = [item];
}
}
return Object.entries(groups).map(([date, items]) => ({ date, items }));
});
const shouldGroupByDate = $derived(!data.test_code);
const notableFlags = new Set(['ABN', 'H', 'L', 'POS']);
const flaggedCount = $derived.by(() =>
data.resultPage.items.reduce((count, item) => {
if (!item.flag) return count;
return notableFlags.has(item.flag) ? count + 1 : count;
}, 0)
);
function formatValue(item: LabResultItem): string {
if (item.value_num != null) {
return `${item.value_num}${item.unit_original ? ` ${item.unit_original}` : ''}`;
}
if (item.value_text) return item.value_text;
if (item.value_bool != null) return item.value_bool ? m['labResults_boolTrue']() : m['labResults_boolFalse']();
return '—';
}
const flagOptions = flags.map((f) => ({ value: f, label: f })); const flagOptions = flags.map((f) => ({ value: f, label: f }));
function onFlagChange(v: string) {
const base = resolve('/health/lab-results');
const target = v ? `${base}?flag=${encodeURIComponent(v)}` : base;
goto(target, { replaceState: true });
}
</script> </script>
<svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head> <svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head>
@ -50,9 +180,20 @@
<section class="editorial-hero reveal-1"> <section class="editorial-hero reveal-1">
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> <p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m["labResults_title"]()}</h2> <h2 class="editorial-title">{m["labResults_title"]()}</h2>
<p class="editorial-subtitle">{m["labResults_count"]({ count: data.results.length })}</p> <p class="editorial-subtitle">{m["labResults_count"]({ count: data.resultPage.total })}</p>
<div class="lab-results-meta-strip">
<span class="lab-results-meta-pill">
{m['labResults_view']()}: {data.latestOnly ? m['labResults_viewLatest']() : m['labResults_viewAll']()}
</span>
<span class="lab-results-meta-pill">{m['labResults_flagFilter']()} {data.flag || m['labResults_flagAll']()}</span>
<span class="lab-results-meta-pill">
{m['labResults_flag']()}: {flaggedCount}
</span>
{#if data.test_code}
<span class="lab-results-meta-pill lab-results-meta-pill--alert">{data.test_code}</span>
{/if}
</div>
<div class="editorial-toolbar"> <div class="editorial-toolbar">
<Button href={resolve('/health/medications')} variant="outline">{m.medications_title()}</Button>
<Button variant="outline" onclick={() => (showForm = !showForm)}> <Button variant="outline" onclick={() => (showForm = !showForm)}>
{showForm ? m.common_cancel() : m["labResults_addNew"]()} {showForm ? m.common_cancel() : m["labResults_addNew"]()}
</Button> </Button>
@ -65,66 +206,254 @@
{#if form?.created} {#if form?.created}
<div class="editorial-alert editorial-alert--success">{m["labResults_added"]()}</div> <div class="editorial-alert editorial-alert--success">{m["labResults_added"]()}</div>
{/if} {/if}
{#if form?.deleted}
<div class="editorial-alert editorial-alert--success">{m["labResults_deleted"]()}</div>
{/if}
{#if form?.updated}
<div class="editorial-alert editorial-alert--success">{m["labResults_updated"]()}</div>
{/if}
<!-- Filter --> {#if editingId}
<div class="editorial-panel reveal-2 flex items-center gap-3"> <FormSectionCard title={m["labResults_editTitle"]()} className="reveal-2">
<span class="text-sm text-muted-foreground">{m["labResults_flagFilter"]()}</span> <form
<select method="POST"
class={`${baseSelectClass} w-32`} action="?/update"
value={filterFlag} use:enhance={() => {
onchange={(e) => onFlagChange(e.currentTarget.value)} return async ({ result, update }) => {
> await update();
<option value="">{m["labResults_flagAll"]()}</option> if (result.type === 'success') editingId = null;
{#each flags as f (f)} };
<option value={f}>{f}</option> }}
{/each} class="grid grid-cols-1 sm:grid-cols-2 gap-4"
</select> >
</div> <input type="hidden" name="id" value={editingId} />
<div class="space-y-1">
{#if showForm} <Label for="edit_collected_at">{m["labResults_date"]()}</Label>
<FormSectionCard title={m["labResults_newTitle"]()} className="reveal-2"> <Input id="edit_collected_at" name="collected_at" type="date" bind:value={editCollectedAt} required />
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4"> </div>
<div class="space-y-1"> <div class="space-y-1">
<Label for="collected_at">{m["labResults_date"]()}</Label> <Label for="edit_test_code">{m["labResults_loincCode"]()}</Label>
<Input id="collected_at" name="collected_at" type="date" required /> <Input id="edit_test_code" name="test_code" bind:value={editTestCode} required />
</div>
<div class="space-y-1">
<Label for="edit_test_name_original">{m["labResults_testName"]()}</Label>
<Input id="edit_test_name_original" name="test_name_original" bind:value={editTestNameOriginal} />
</div>
<div class="space-y-1">
<Label for="edit_test_name_loinc">{m["labResults_loincName"]()}</Label>
<Input id="edit_test_name_loinc" name="test_name_loinc" bind:value={editTestNameLoinc} />
</div>
<div class="space-y-1">
<Label for="edit_value_mode">{m["labResults_valueType"]()}</Label>
<select class={`${baseSelectClass} w-full`} id="edit_value_mode" name="value_mode" bind:value={editValueMode}>
<option value="num">{m["labResults_valueTypeNumeric"]()}</option>
<option value="text">{m["labResults_valueTypeText"]()}</option>
<option value="bool">{m["labResults_valueTypeBoolean"]()}</option>
<option value="empty">{m["labResults_valueTypeEmpty"]()}</option>
</select>
</div>
<div class="space-y-1">
{#if editValueMode === 'num'}
<Label for="edit_value_num">{m["labResults_value"]()}</Label>
<Input id="edit_value_num" name="value_num" type="number" step="any" bind:value={editValueNum} />
{:else if editValueMode === 'text'}
<Label for="edit_value_text">{m["labResults_value"]()}</Label>
<Input id="edit_value_text" name="value_text" bind:value={editValueText} />
{:else if editValueMode === 'bool'}
<Label for="edit_value_bool">{m["labResults_value"]()}</Label>
<select class={`${baseSelectClass} w-full`} id="edit_value_bool" name="value_bool" bind:value={editValueBool}>
<option value="">{m["labResults_flagNone"]()}</option>
<option value="true">{m["labResults_boolTrue"]()}</option>
<option value="false">{m["labResults_boolFalse"]()}</option>
</select>
{:else}
<Label for="edit_value_placeholder">{m["labResults_value"]()}</Label>
<Input id="edit_value_placeholder" value={m["labResults_valueEmpty"]()} disabled />
{/if}
</div>
<div class="space-y-1">
<Label for="edit_unit_original">{m["labResults_unit"]()}</Label>
<Input id="edit_unit_original" name="unit_original" bind:value={editUnitOriginal} />
</div>
<div class="space-y-1">
<Label for="edit_flag">{m["labResults_flag"]()}</Label>
<select class={`${baseSelectClass} w-full`} id="edit_flag" name="flag" bind:value={editFlag}>
<option value="">{m["labResults_flagNone"]()}</option>
{#each flags as f (f)}
<option value={f}>{f}</option>
{/each}
</select>
</div>
<div class="space-y-1">
<Label for="edit_lab">{m["labResults_lab"]()}</Label>
<Input id="edit_lab" name="lab" bind:value={editLab} />
</div>
<details class="sm:col-span-2 rounded-xl border p-3">
<summary class="cursor-pointer text-sm text-muted-foreground">{m["labResults_advanced"]()}</summary>
<div class="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1">
<Label for="edit_unit_ucum">{m["labResults_unitUcum"]()}</Label>
<Input id="edit_unit_ucum" name="unit_ucum" bind:value={editUnitUcum} />
</div>
<div class="space-y-1">
<Label for="edit_ref_low">{m["labResults_refLow"]()}</Label>
<Input id="edit_ref_low" name="ref_low" type="number" step="any" bind:value={editRefLow} />
</div>
<div class="space-y-1">
<Label for="edit_ref_high">{m["labResults_refHigh"]()}</Label>
<Input id="edit_ref_high" name="ref_high" type="number" step="any" bind:value={editRefHigh} />
</div>
<div class="space-y-1">
<Label for="edit_ref_text">{m["labResults_refText"]()}</Label>
<Input id="edit_ref_text" name="ref_text" bind:value={editRefText} />
</div>
<div class="space-y-1">
<Label for="edit_source_file">{m["labResults_sourceFile"]()}</Label>
<Input id="edit_source_file" name="source_file" bind:value={editSourceFile} />
</div>
<div class="space-y-1 sm:col-span-2">
<Label for="edit_notes">{m["labResults_notes"]()}</Label>
<Input id="edit_notes" name="notes" bind:value={editNotes} />
</div>
</div> </div>
<div class="space-y-1"> </details>
<Label for="test_code">{m["labResults_loincCode"]()} <span class="text-xs text-muted-foreground">({m["labResults_loincExample"]()})</span></Label> <div class="sm:col-span-2 flex justify-end gap-2">
<Input id="test_code" name="test_code" required placeholder="718-7" /> <Button type="button" variant="outline" onclick={cancelEdit}>{m.common_cancel()}</Button>
</div> <Button type="submit">{m.common_save()}</Button>
<div class="space-y-1"> </div>
<Label for="test_name_original">{m["labResults_testName"]()}</Label> </form>
<Input id="test_name_original" name="test_name_original" placeholder={m["labResults_testNamePlaceholder"]()} />
</div>
<div class="space-y-1">
<Label for="lab">{m["labResults_lab"]()}</Label>
<Input id="lab" name="lab" placeholder={m["labResults_labPlaceholder"]()} />
</div>
<div class="space-y-1">
<Label for="value_num">{m["labResults_value"]()}</Label>
<Input id="value_num" name="value_num" type="number" step="any" />
</div>
<div class="space-y-1">
<Label for="unit_original">{m["labResults_unit"]()}</Label>
<Input id="unit_original" name="unit_original" placeholder={m["labResults_unitPlaceholder"]()} />
</div>
<SimpleSelect
id="flag"
name="flag"
label={m["labResults_flag"]()}
options={flagOptions}
placeholder={m["labResults_flagNone"]()}
bind:value={selectedFlag}
/>
<div class="flex items-end">
<Button type="submit">{m.common_add()}</Button>
</div>
</form>
</FormSectionCard> </FormSectionCard>
{/if} {/if}
<form method="GET" class="editorial-panel lab-results-filter-panel reveal-2 grid grid-cols-1 md:grid-cols-5 gap-3">
<div class="md:col-span-2">
<Label for="q">{m["labResults_search"]()}</Label>
<Input id="q" name="q" value={data.q ?? ''} placeholder={m["labResults_searchPlaceholder"]()} />
</div>
<div>
<Label for="flag">{m["labResults_flagFilter"]()}</Label>
<select class={`${baseSelectClass} w-full`} id="flag" name="flag" value={data.flag ?? ''}>
<option value="">{m["labResults_flagAll"]()}</option>
{#each flags as f (f)}
<option value={f}>{f}</option>
{/each}
</select>
</div>
<div>
<Label for="from_date">{m["labResults_from"]()}</Label>
<Input id="from_date" name="from_date" type="date" value={(data.from_date ?? '').slice(0, 10)} />
</div>
<div>
<Label for="to_date">{m["labResults_to"]()}</Label>
<Input id="to_date" name="to_date" type="date" value={(data.to_date ?? '').slice(0, 10)} />
</div>
<div>
<Label for="latest_only">{m["labResults_view"]()}</Label>
<select class={`${baseSelectClass} w-full`} id="latest_only" name="latest_only">
<option value="true" selected={data.latestOnly}>{m["labResults_viewLatest"]()}</option>
<option value="false" selected={!data.latestOnly}>{m["labResults_viewAll"]()}</option>
</select>
</div>
<div class="md:col-span-6 flex items-center gap-2">
{#if data.test_code}
<input type="hidden" name="test_code" value={data.test_code} />
{/if}
<Button type="submit">{m["labResults_applyFilters"]()}</Button>
<Button type="button" variant="outline" onclick={() => (window.location.href = '/health/lab-results')}>
{m["labResults_resetAllFilters"]()}
</Button>
</div>
</form>
{#if data.test_code}
<div class="editorial-panel lab-results-filter-banner reveal-2 flex items-center justify-between gap-3">
<p class="text-sm text-muted-foreground">
{m['labResults_filteredByCode']({ code: data.test_code })}
</p>
<Button
type="button"
variant="outline"
onclick={clearCodeFilterOnly}
>
{m['labResults_clearCodeFilter']()}
</Button>
</div>
{/if}
{#if showForm}
<FormSectionCard title={m["labResults_newTitle"]()} className="reveal-2">
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1">
<Label for="collected_at">{m["labResults_date"]()}</Label>
<Input id="collected_at" name="collected_at" type="date" required />
</div>
<div class="space-y-1">
<Label for="test_code"
>{m["labResults_loincCode"]()} <span class="text-xs text-muted-foreground"
>({m["labResults_loincExample"]()})</span
></Label
>
<Input id="test_code" name="test_code" required placeholder="718-7" />
</div>
<div class="space-y-1">
<Label for="test_name_original">{m["labResults_testName"]()}</Label>
<Input
id="test_name_original"
name="test_name_original"
placeholder={m["labResults_testNamePlaceholder"]()}
/>
</div>
<div class="space-y-1">
<Label for="lab_create">{m["labResults_lab"]()}</Label>
<Input id="lab_create" name="lab" placeholder={m["labResults_labPlaceholder"]()} />
</div>
<div class="space-y-1">
<Label for="value_num">{m["labResults_value"]()}</Label>
<Input id="value_num" name="value_num" type="number" step="any" />
</div>
<div class="space-y-1">
<Label for="unit_original">{m["labResults_unit"]()}</Label>
<Input id="unit_original" name="unit_original" placeholder={m["labResults_unitPlaceholder"]()} />
</div>
<SimpleSelect
id="flag_create"
name="flag"
label={m["labResults_flag"]()}
options={flagOptions}
placeholder={m["labResults_flagNone"]()}
bind:value={selectedFlag}
/>
<div class="flex items-end">
<Button type="submit">{m.common_add()}</Button>
</div>
</form>
</FormSectionCard>
{/if}
{#if data.totalPages > 1}
<div class="editorial-panel lab-results-pager reveal-2 flex items-center justify-between gap-3">
<Button
variant="outline"
disabled={data.page <= 1}
onclick={() => (window.location.href = buildPageUrl(data.page - 1))}
>
{m["labResults_previous"]()}
</Button>
<p class="text-sm text-muted-foreground">
{m["labResults_pageIndicator"]({ page: data.page, total: data.totalPages })}
</p>
<Button
variant="outline"
disabled={data.page >= data.totalPages}
onclick={() => (window.location.href = buildPageUrl(data.page + 1))}
>
{m["labResults_next"]()}
</Button>
</div>
{/if}
<!-- Desktop: table --> <!-- Desktop: table -->
<div class="products-table-shell hidden md:block reveal-2"> <div class="products-table-shell lab-results-table hidden md:block reveal-2">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -134,72 +463,289 @@
<TableHead>{m["labResults_colValue"]()}</TableHead> <TableHead>{m["labResults_colValue"]()}</TableHead>
<TableHead>{m["labResults_colFlag"]()}</TableHead> <TableHead>{m["labResults_colFlag"]()}</TableHead>
<TableHead>{m["labResults_colLab"]()}</TableHead> <TableHead>{m["labResults_colLab"]()}</TableHead>
<TableHead class="text-right">{m["labResults_colActions"]()}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{#each data.results as r (r.record_id)} {#if shouldGroupByDate}
<TableRow> {#each groupedByDate as group (group.date)}
<TableCell class="text-sm">{r.collected_at.slice(0, 10)}</TableCell> <TableRow>
<TableCell class="font-medium">{r.test_name_original ?? r.test_code}</TableCell> <TableCell colspan={7} class="bg-muted/35 py-2">
<TableCell class="text-xs text-muted-foreground font-mono">{r.test_code}</TableCell> <div class="products-section-title text-xs uppercase tracking-[0.12em]">
<TableCell> {group.date}
{#if r.value_num != null} </div>
{r.value_num} {r.unit_original ?? ''} </TableCell>
{:else if r.value_text} </TableRow>
{r.value_text} {#each group.items as r (r.record_id)}
{:else} <TableRow class="lab-results-row">
<TableCell class="text-sm">{r.collected_at.slice(0, 10)}</TableCell>
{/if} <TableCell class="font-medium">
</TableCell> <button
<TableCell> type="button"
{#if r.flag} onclick={() => filterByCode(r.test_code)}
<span class={flagPills[r.flag] ?? 'health-flag-pill'}> class="lab-results-code-link text-left"
{r.flag} >
</span> {r.test_name_original ?? r.test_code}
{:else} </button>
</TableCell>
{/if} <TableCell class="text-xs text-muted-foreground font-mono">
</TableCell> <button
<TableCell class="text-sm text-muted-foreground">{r.lab ?? '—'}</TableCell> type="button"
</TableRow> onclick={() => filterByCode(r.test_code)}
class="lab-results-code-link"
>
{r.test_code}
</button>
</TableCell>
<TableCell class="lab-results-value-cell">
{formatValue(r)}
</TableCell>
<TableCell>
{#if r.flag}
<span class={flagPills[r.flag] ?? 'health-flag-pill'}>
{r.flag}
</span>
{:else}
{/if}
</TableCell>
<TableCell class="text-sm text-muted-foreground">{r.lab ?? '—'}</TableCell>
<TableCell class="text-right">
<div class="lab-results-row-actions flex justify-end gap-1">
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 w-7 shrink-0 p-0"
onclick={() => startEdit(r)}
aria-label={m.common_edit()}
>
<Pencil class="size-4" />
</Button>
<form
method="POST"
action="?/delete"
use:enhance
onsubmit={(event) => {
if (!confirm(m['labResults_confirmDelete']())) event.preventDefault();
}}
>
<input type="hidden" name="id" value={r.record_id} />
<Button
type="submit"
variant="ghost"
size="sm"
class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive"
aria-label={m.common_delete()}
>
<X class="size-4" />
</Button>
</form>
</div>
</TableCell>
</TableRow>
{/each}
{/each}
{:else} {:else}
{#each data.resultPage.items as r (r.record_id)}
<TableRow class="lab-results-row">
<TableCell class="text-sm">{r.collected_at.slice(0, 10)}</TableCell>
<TableCell class="font-medium">
<button
type="button"
onclick={() => filterByCode(r.test_code)}
class="lab-results-code-link text-left"
>
{r.test_name_original ?? r.test_code}
</button>
</TableCell>
<TableCell class="text-xs text-muted-foreground font-mono">
<button
type="button"
onclick={() => filterByCode(r.test_code)}
class="lab-results-code-link"
>
{r.test_code}
</button>
</TableCell>
<TableCell class="lab-results-value-cell">
{formatValue(r)}
</TableCell>
<TableCell>
{#if r.flag}
<span class={flagPills[r.flag] ?? 'health-flag-pill'}>
{r.flag}
</span>
{:else}
{/if}
</TableCell>
<TableCell class="text-sm text-muted-foreground">{r.lab ?? '—'}</TableCell>
<TableCell class="text-right">
<div class="lab-results-row-actions flex justify-end gap-1">
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 w-7 shrink-0 p-0"
onclick={() => startEdit(r)}
aria-label={m.common_edit()}
>
<Pencil class="size-4" />
</Button>
<form
method="POST"
action="?/delete"
use:enhance
onsubmit={(event) => {
if (!confirm(m["labResults_confirmDelete"]())) event.preventDefault();
}}
>
<input type="hidden" name="id" value={r.record_id} />
<Button
type="submit"
variant="ghost"
size="sm"
class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive"
aria-label={m.common_delete()}
>
<X class="size-4" />
</Button>
</form>
</div>
</TableCell>
</TableRow>
{/each}
{/if}
{#if data.resultPage.items.length === 0}
<TableRow> <TableRow>
<TableCell colspan={6} class="text-center text-muted-foreground py-8"> <TableCell colspan={7} class="text-center text-muted-foreground py-8">
{m["labResults_noResults"]()} {m["labResults_noResults"]()}
</TableCell> </TableCell>
</TableRow> </TableRow>
{/each} {/if}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<!-- Mobile: cards --> <!-- Mobile: cards -->
<div class="flex flex-col gap-3 md:hidden reveal-3"> <div class="lab-results-mobile-grid flex flex-col gap-3 md:hidden reveal-3">
{#each data.results as r (r.record_id)} {#if shouldGroupByDate}
<div class="products-mobile-card flex flex-col gap-1"> {#each groupedByDate as group (group.date)}
<div class="flex items-start justify-between gap-2"> <div class="products-section-title text-xs uppercase tracking-[0.12em]">{group.date}</div>
<span class="font-medium">{r.test_name_original ?? r.test_code}</span> {#each group.items as r (r.record_id)}
{#if r.flag} <div class="products-mobile-card lab-results-mobile-card flex flex-col gap-1">
<span class={flagPills[r.flag] ?? 'health-flag-pill'}> <div class="flex items-start justify-between gap-2">
{r.flag} <div class="flex min-w-0 flex-col gap-1">
</span> <button
{/if} type="button"
</div> onclick={() => filterByCode(r.test_code)}
<p class="text-sm text-muted-foreground">{r.collected_at.slice(0, 10)}</p> class="lab-results-code-link text-left font-medium"
<div class="flex items-center gap-2 text-sm"> >
<span class="font-mono text-xs text-muted-foreground">{r.test_code}</span> {r.test_name_original ?? r.test_code}
{#if r.value_num != null} </button>
<span>{r.value_num} {r.unit_original ?? ''}</span> </div>
{:else if r.value_text} {#if r.flag}
<span>{r.value_text}</span> <span class={flagPills[r.flag] ?? 'health-flag-pill'}>
{/if} {r.flag}
</div> </span>
{#if r.lab} {/if}
<p class="text-xs text-muted-foreground">{r.lab}</p> </div>
{/if} <p class="text-sm text-muted-foreground">{r.collected_at.slice(0, 10)}</p>
</div> <div class="lab-results-mobile-value flex items-center gap-2 text-sm">
<button
type="button"
onclick={() => filterByCode(r.test_code)}
class="lab-results-code-link font-mono text-xs text-muted-foreground"
>
{r.test_code}
</button>
<span>{formatValue(r)}</span>
</div>
{#if r.lab}
<p class="text-xs text-muted-foreground">{r.lab}</p>
{/if}
<div class="lab-results-mobile-actions flex gap-1">
<Button type="button" variant="ghost" size="sm" onclick={() => startEdit(r)}>
<Pencil class="size-4" />
{m.common_edit()}
</Button>
<form
method="POST"
action="?/delete"
use:enhance
onsubmit={(event) => {
if (!confirm(m['labResults_confirmDelete']())) event.preventDefault();
}}
>
<input type="hidden" name="id" value={r.record_id} />
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">
<X class="size-4" />
{m.common_delete()}
</Button>
</form>
</div>
</div>
{/each}
{/each}
{:else} {:else}
{#each data.resultPage.items as r (r.record_id)}
<div class="products-mobile-card lab-results-mobile-card flex flex-col gap-1">
<div class="flex items-start justify-between gap-2">
<div class="flex min-w-0 flex-col gap-1">
<button
type="button"
onclick={() => filterByCode(r.test_code)}
class="lab-results-code-link text-left font-medium"
>
{r.test_name_original ?? r.test_code}
</button>
</div>
{#if r.flag}
<span class={flagPills[r.flag] ?? 'health-flag-pill'}>
{r.flag}
</span>
{/if}
</div>
<p class="text-sm text-muted-foreground">{r.collected_at.slice(0, 10)}</p>
<div class="lab-results-mobile-value flex items-center gap-2 text-sm">
<button
type="button"
onclick={() => filterByCode(r.test_code)}
class="lab-results-code-link font-mono text-xs text-muted-foreground"
>
{r.test_code}
</button>
<span>{formatValue(r)}</span>
</div>
{#if r.lab}
<p class="text-xs text-muted-foreground">{r.lab}</p>
{/if}
<div class="lab-results-mobile-actions flex gap-1">
<Button type="button" variant="ghost" size="sm" onclick={() => startEdit(r)}>
<Pencil class="size-4" />
{m.common_edit()}
</Button>
<form
method="POST"
action="?/delete"
use:enhance
onsubmit={(event) => {
if (!confirm(m["labResults_confirmDelete"]())) event.preventDefault();
}}
>
<input type="hidden" name="id" value={r.record_id} />
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">
<X class="size-4" />
{m.common_delete()}
</Button>
</form>
</div>
</div>
{/each}
{/if}
{#if data.resultPage.items.length === 0}
<p class="py-8 text-center text-sm text-muted-foreground">{m["labResults_noResults"]()}</p> <p class="py-8 text-center text-sm text-muted-foreground">{m["labResults_noResults"]()}</p>
{/each} {/if}
</div> </div>
</div> </div>

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
@ -43,7 +42,6 @@
<h2 class="editorial-title">{m.medications_title()}</h2> <h2 class="editorial-title">{m.medications_title()}</h2>
<p class="editorial-subtitle">{m.medications_count({ count: data.medications.length })}</p> <p class="editorial-subtitle">{m.medications_count({ count: data.medications.length })}</p>
<div class="editorial-toolbar"> <div class="editorial-toolbar">
<Button href={resolve('/health/lab-results')} variant="outline">{m["labResults_title"]()}</Button>
<Button variant="outline" onclick={() => (showForm = !showForm)}> <Button variant="outline" onclick={() => (showForm = !showForm)}>
{showForm ? m.common_cancel() : m["medications_addNew"]()} {showForm ? m.common_cancel() : m["medications_addNew"]()}
</Button> </Button>

View file

@ -90,7 +90,7 @@
</div> </div>
<div class="editorial-page space-y-4 pb-20 md:pb-0"> <div class="editorial-page space-y-4 pb-20 md:pb-0">
<section class="editorial-panel reveal-1 space-y-3"> <section class="editorial-hero reveal-1 space-y-3">
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a> <a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> <p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="break-words editorial-title">{product.name}</h2> <h2 class="break-words editorial-title">{product.name}</h2>

View file

@ -21,7 +21,7 @@
<svelte:head><title>{m["products_newTitle"]()} — innercontext</title></svelte:head> <svelte:head><title>{m["products_newTitle"]()} — innercontext</title></svelte:head>
<div class="editorial-page space-y-4"> <div class="editorial-page space-y-4">
<section class="editorial-panel reveal-1 space-y-3"> <section class="editorial-hero reveal-1 space-y-3">
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a> <a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> <p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m["products_newTitle"]()}</h2> <h2 class="editorial-title">{m["products_newTitle"]()}</h2>

View file

@ -33,10 +33,11 @@
<svelte:head><title>{m["products_suggestTitle"]()} — innercontext</title></svelte:head> <svelte:head><title>{m["products_suggestTitle"]()} — innercontext</title></svelte:head>
<div class="editorial-page space-y-4"> <div class="editorial-page space-y-4">
<section class="editorial-panel reveal-1 space-y-3"> <section class="editorial-hero reveal-1 space-y-3">
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a> <a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> <p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m["products_suggestTitle"]()}</h2> <h2 class="editorial-title">{m["products_suggestTitle"]()}</h2>
<p class="editorial-subtitle">{m["products_suggestSubtitle"]()}</p>
</section> </section>
{#if errorMsg} {#if errorMsg}
@ -112,7 +113,7 @@
<form method="POST" action="?/suggest" use:enhance={enhanceForm}> <form method="POST" action="?/suggest" use:enhance={enhanceForm}>
<Button variant="outline" type="submit" disabled={loading}> <Button variant="outline" type="submit" disabled={loading}>
{m["products_suggestRegenerate"]()} <Sparkles class="size-4" /> {m["products_suggestRegenerate"]()}
</Button> </Button>
</form> </form>
{:else if suggestions && suggestions.length === 0} {:else if suggestions && suggestions.length === 0}

View file

@ -4,6 +4,7 @@
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Sparkles } from 'lucide-svelte';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@ -28,7 +29,7 @@
<h2 class="editorial-title">{m.routines_title()}</h2> <h2 class="editorial-title">{m.routines_title()}</h2>
<p class="editorial-subtitle">{m.routines_count({ count: data.routines.length })}</p> <p class="editorial-subtitle">{m.routines_count({ count: data.routines.length })}</p>
<div class="editorial-toolbar"> <div class="editorial-toolbar">
<Button href={resolve('/routines/suggest')} variant="outline">{m["routines_suggestAI"]()}</Button> <Button href={resolve('/routines/suggest')} variant="outline"><Sparkles class="size-4" /> {m["routines_suggestAI"]()}</Button>
<Button href={resolve('/routines/new')}>{m["routines_addNew"]()}</Button> <Button href={resolve('/routines/new')}>{m["routines_addNew"]()}</Button>
</div> </div>
</section> </section>

View file

@ -149,7 +149,7 @@
<svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head> <svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head>
<div class="editorial-page space-y-4"> <div class="editorial-page space-y-4">
<section class="editorial-panel reveal-1 space-y-3"> <section class="editorial-hero reveal-1 space-y-3">
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a> <a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> <p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">

View file

@ -48,17 +48,15 @@
<svelte:head><title>{m.grooming_title()} — innercontext</title></svelte:head> <svelte:head><title>{m.grooming_title()} — innercontext</title></svelte:head>
<div class="editorial-page space-y-4"> <div class="editorial-page space-y-4">
<section class="editorial-panel reveal-1"> <section class="editorial-hero reveal-1 space-y-3">
<div class="flex items-center justify-between"> <a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["grooming_backToRoutines"]()}</a>
<div> <p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["grooming_backToRoutines"]()}</a> <h2 class="editorial-title text-[clamp(1.8rem,3vw,2.4rem)]">{m.grooming_title()}</h2>
<p class="editorial-kicker mt-2">{m["nav_appSubtitle"]()}</p> <div class="editorial-toolbar">
<h2 class="editorial-title mt-1 text-[clamp(1.8rem,3vw,2.4rem)]">{m.grooming_title()}</h2> <Button variant="outline" size="sm" onclick={() => (showAddForm = !showAddForm)}>
{showAddForm ? m.common_cancel() : m["grooming_addEntry"]()}
</Button>
</div> </div>
<Button variant="outline" size="sm" onclick={() => (showAddForm = !showAddForm)}>
{showAddForm ? m.common_cancel() : m["grooming_addEntry"]()}
</Button>
</div>
</section> </section>
{#if form?.error} {#if form?.error}

View file

@ -22,7 +22,7 @@
<svelte:head><title>{m["routines_newTitle"]()} — innercontext</title></svelte:head> <svelte:head><title>{m["routines_newTitle"]()} — innercontext</title></svelte:head>
<div class="editorial-page space-y-4"> <div class="editorial-page space-y-4">
<section class="editorial-panel reveal-1 space-y-3"> <section class="editorial-hero reveal-1 space-y-3">
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a> <a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> <p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m["routines_newTitle"]()}</h2> <h2 class="editorial-title">{m["routines_newTitle"]()}</h2>

View file

@ -15,7 +15,7 @@
import HintCheckbox from '$lib/components/forms/HintCheckbox.svelte'; import HintCheckbox from '$lib/components/forms/HintCheckbox.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte'; import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
import { ChevronUp, ChevronDown, ArrowLeft } from 'lucide-svelte'; import { ChevronUp, ChevronDown, ArrowLeft, Sparkles } from 'lucide-svelte';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@ -115,7 +115,7 @@
<svelte:head><title>{m.suggest_title()} — innercontext</title></svelte:head> <svelte:head><title>{m.suggest_title()} — innercontext</title></svelte:head>
<div class="editorial-page space-y-4"> <div class="editorial-page space-y-4">
<section class="editorial-panel reveal-1 space-y-3"> <section class="editorial-hero reveal-1 space-y-3">
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["suggest_backToRoutines"]()}</a> <a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["suggest_backToRoutines"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> <p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="editorial-title">{m.suggest_title()}</h2> <h2 class="editorial-title">{m.suggest_title()}</h2>
@ -179,7 +179,7 @@
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span> <span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
{m.suggest_generating()} {m.suggest_generating()}
{:else} {:else}
{m["suggest_generateBtn"]()} <Sparkles class="size-4" /> {m["suggest_generateBtn"]()}
{/if} {/if}
</Button> </Button>
</form> </form>
@ -255,7 +255,7 @@
{/if} {/if}
</Button> </Button>
<Button variant="outline" type="submit" form="suggest-single-form" disabled={loadingSingle}> <Button variant="outline" type="submit" form="suggest-single-form" disabled={loadingSingle}>
{m.suggest_regenerate()} <Sparkles class="size-4" /> {m.suggest_regenerate()}
</Button> </Button>
</form> </form>
</div> </div>
@ -305,7 +305,7 @@
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span> <span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
{m["suggest_generatingPlan"]()} {m["suggest_generatingPlan"]()}
{:else} {:else}
{m["suggest_generatePlan"]()} <Sparkles class="size-4" /> {m["suggest_generatePlan"]()}
{/if} {/if}
</Button> </Button>
</form> </form>
@ -420,7 +420,7 @@
{/if} {/if}
</Button> </Button>
<Button variant="outline" type="submit" form="batch-form" disabled={loadingBatch}> <Button variant="outline" type="submit" form="batch-form" disabled={loadingBatch}>
{m.suggest_regenerate()} <Sparkles class="size-4" /> {m.suggest_regenerate()}
</Button> </Button>
</form> </form>
</div> </div>