feat(frontend): unify page shell and move create flows to dedicated routes

This commit is contained in:
Piotr Oleszczyk 2026-03-10 12:25:25 +01:00
parent e20c18c2ee
commit 0253b2377d
50 changed files with 2235 additions and 1042 deletions

View file

@ -80,10 +80,12 @@ Use these wrappers before introducing route-specific structure:
- `editorial-page`: standard constrained content width for route pages. - `editorial-page`: standard constrained content width for route pages.
- `editorial-hero`: top summary strip for title, subtitle, and primary actions. - `editorial-hero`: top summary strip for title, subtitle, and primary actions.
- `PageHeader.svelte`: preferred reusable wrapper for page-level hero sections; use it to keep title hierarchy, backlinks, meta rows, and action placement consistent.
- `editorial-panel`: primary surface for forms, tables, and ledgers. - `editorial-panel`: primary surface for forms, tables, and ledgers.
- `editorial-toolbar`: compact action row under hero copy. - `editorial-toolbar`: compact action row under hero copy.
- `editorial-backlink`: standard top-left back navigation style. - `editorial-backlink`: standard top-left back navigation style.
- `editorial-alert`, `editorial-alert--error`, `editorial-alert--success`, `editorial-alert--warning`, `editorial-alert--info`: feedback banners. - `editorial-alert`, `editorial-alert--error`, `editorial-alert--success`, `editorial-alert--warning`, `editorial-alert--info`: feedback banners.
- `page-header-meta`, `page-header-foot`, `hero-strip`: shared secondary rows inside page headers for compact metadata and summary stats.
### Collapsible panels ### Collapsible panels
@ -117,10 +119,14 @@ This matches the warm editorial aesthetic and maintains visual consistency with
These classes are already in use and should be reused: These classes are already in use and should be reused:
- Lists and ledgers: `routine-ledger-row`, `products-mobile-card`, `health-entry-row` - Lists and ledgers: `routine-ledger-row`, `editorial-mobile-card`, `health-entry-row`
- Group headers: `products-section-title` - Group headers: `editorial-section-title`
- Table shell: `products-table-shell` - Table shell: `editorial-table-shell`
- Compact metadata rows: `editorial-meta-strip`
- Tabs shell: `products-tabs`, `editorial-tabs` - Tabs shell: `products-tabs`, `editorial-tabs`
- App shell/navigation: `app-mobile-header`, `app-drawer`, `app-nav-list`, `app-nav-link`, `app-sidebar-footer`
- Reusable locale control: `LanguageSwitcher.svelte` with `language-switcher*` classes
- Dashboard summary patterns: `dashboard-stat-strip`, `dashboard-stat-card`, `dashboard-attention-list`, `dashboard-attention-item`
- Health semantic pills: `health-kind-pill*`, `health-flag-pill*` - Health semantic pills: `health-kind-pill*`, `health-flag-pill*`
- Lab results utilities: - Lab results utilities:
- metadata chips: `lab-results-meta-strip`, `lab-results-meta-pill` - metadata chips: `lab-results-meta-strip`, `lab-results-meta-pill`
@ -145,6 +151,7 @@ These classes are already in use and should be reused:
- 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. - 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. - 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. - 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.
- For dashboard-style summaries, prefer compact stat strips and attention rows over large decorative cards; each item should pair one strong value with one short explanatory line.
### DRY form primitives ### DRY form primitives
@ -211,6 +218,7 @@ These classes are already in use and should be reused:
- Core tokens and global look: `frontend/src/app.css` - Core tokens and global look: `frontend/src/app.css`
- App shell and route domain mapping: `frontend/src/routes/+layout.svelte` - App shell and route domain mapping: `frontend/src/routes/+layout.svelte`
- Shared page header: `frontend/src/lib/components/PageHeader.svelte`
- Route examples using the pattern: - Route examples using the pattern:
- `frontend/src/routes/+page.svelte` - `frontend/src/routes/+page.svelte`
- `frontend/src/routes/products/+page.svelte` - `frontend/src/routes/products/+page.svelte`

View file

@ -32,8 +32,74 @@
"dashboard_title": "Dashboard", "dashboard_title": "Dashboard",
"dashboard_subtitle": "Your recent health & skincare overview", "dashboard_subtitle": "Your recent health & skincare overview",
"dashboard_dailyBriefing": "A quick read on what changed, what is missing, and where to look next.",
"dashboard_latestSnapshot": "Latest Skin Snapshot", "dashboard_latestSnapshot": "Latest Skin Snapshot",
"dashboard_recentRoutines": "Recent Routines", "dashboard_recentRoutines": "Recent Routines",
"dashboard_requiresAttention": "Requires attention",
"dashboard_healthPulse": "Health Pulse",
"dashboard_viewSkinHistory": "Open skin history",
"dashboard_viewLabResults": "Open lab results",
"dashboard_heroFreshness": "Freshness",
"dashboard_sinceLastSnapshot": "since last snapshot",
"dashboard_skinFreshness": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Last snapshot was {count} day ago.",
"countPlural=*": "Last snapshot was {count} days ago."
}
}
],
"dashboard_daysAgo": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} day ago",
"countPlural=*": "{count} days ago"
}
}
],
"dashboard_daysAgoShort": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count}d",
"countPlural=*": "{count}d"
}
}
],
"dashboard_flaggedResults": "flagged results",
"dashboard_flaggedLabsCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} flagged result",
"countPlural=*": "{count} flagged results"
}
}
],
"dashboard_attentionSnapshot": "Skin log",
"dashboard_attentionRoutineAM": "AM routine",
"dashboard_attentionRoutinePM": "PM routine",
"dashboard_attentionLabs": "Lab review",
"dashboard_attentionMissing": "Missing",
"dashboard_attentionToday": "Today",
"dashboard_attentionStable": "No flagged items",
"dashboard_statusLogged": "Logged",
"dashboard_statusOpen": "Open",
"dashboard_metricHydration": "Hydration",
"dashboard_metricSensitivity": "Sensitivity",
"dashboard_metricSebumTzone": "Sebum T-zone",
"dashboard_metricSebumCheeks": "Sebum cheeks",
"dashboard_metricDelta": "Delta {delta}",
"dashboard_averageSteps": "Avg. {count} steps",
"dashboard_lastRoutine": "Last routine",
"dashboard_lastLabDate": "Last collection",
"dashboard_noLabResults": "No lab results yet.",
"dashboard_noSnapshots": "No skin snapshots yet.", "dashboard_noSnapshots": "No skin snapshots yet.",
"dashboard_noRoutines": "No routines in the past 2 weeks.", "dashboard_noRoutines": "No routines in the past 2 weeks.",
@ -152,6 +218,9 @@
"grooming_title": "Grooming Schedule", "grooming_title": "Grooming Schedule",
"grooming_backToRoutines": "Routines", "grooming_backToRoutines": "Routines",
"grooming_addEntry": "+ Add entry", "grooming_addEntry": "+ Add entry",
"grooming_newTitle": "New grooming entry",
"grooming_newSubtitle": "Add a recurring entry to your weekly grooming schedule.",
"grooming_newSectionIntro": "Choose the day, action, and an optional note.",
"grooming_entryAdded": "Entry added.", "grooming_entryAdded": "Entry added.",
"grooming_entryUpdated": "Entry updated.", "grooming_entryUpdated": "Entry updated.",
"grooming_entryDeleted": "Entry deleted.", "grooming_entryDeleted": "Entry deleted.",
@ -254,6 +323,8 @@
], ],
"medications_addNew": "+ Add medication", "medications_addNew": "+ Add medication",
"medications_newTitle": "New medication", "medications_newTitle": "New medication",
"medications_newSubtitle": "Add a basic medication or supplement record for later tracking.",
"medications_newSectionIntro": "Start with the type, product name, and active substance.",
"medications_kind": "Kind", "medications_kind": "Kind",
"medications_productName": "Product name *", "medications_productName": "Product name *",
"medications_productNamePlaceholder": "e.g. Vitamin D3", "medications_productNamePlaceholder": "e.g. Vitamin D3",
@ -291,6 +362,8 @@
], ],
"labResults_addNew": "+ Add result", "labResults_addNew": "+ Add result",
"labResults_newTitle": "New lab result", "labResults_newTitle": "New lab result",
"labResults_newSubtitle": "Save a single lab result and add it to your health history.",
"labResults_newSectionIntro": "Start with the date and LOINC code, then add the remaining details.",
"labResults_flagFilter": "Flag:", "labResults_flagFilter": "Flag:",
"labResults_flagAll": "All", "labResults_flagAll": "All",
"labResults_flagNone": "None", "labResults_flagNone": "None",
@ -374,6 +447,8 @@
"skin_analyzePhotos": "Analyze photos", "skin_analyzePhotos": "Analyze photos",
"skin_analyzing": "Analyzing…", "skin_analyzing": "Analyzing…",
"skin_newSnapshotTitle": "New skin snapshot", "skin_newSnapshotTitle": "New skin snapshot",
"skin_newSubtitle": "Capture todays skin state manually or prefill the form with AI photo analysis.",
"skin_newSectionIntro": "Start with the date and overall condition, then refine the details.",
"skin_date": "Date *", "skin_date": "Date *",
"skin_overallState": "Overall state", "skin_overallState": "Overall state",
"skin_texture": "Texture", "skin_texture": "Texture",

View file

@ -32,8 +32,82 @@
"dashboard_title": "Dashboard", "dashboard_title": "Dashboard",
"dashboard_subtitle": "Przegląd zdrowia i pielęgnacji", "dashboard_subtitle": "Przegląd zdrowia i pielęgnacji",
"dashboard_dailyBriefing": "Szybki rzut oka na zmiany, braki i miejsca, które warto teraz sprawdzić.",
"dashboard_latestSnapshot": "Ostatni stan skóry", "dashboard_latestSnapshot": "Ostatni stan skóry",
"dashboard_recentRoutines": "Ostatnie rutyny", "dashboard_recentRoutines": "Ostatnie rutyny",
"dashboard_requiresAttention": "Wymaga uwagi",
"dashboard_healthPulse": "Puls zdrowia",
"dashboard_viewSkinHistory": "Otwórz historię skóry",
"dashboard_viewLabResults": "Otwórz wyniki badań",
"dashboard_heroFreshness": "Świeżość",
"dashboard_sinceLastSnapshot": "od ostatniego wpisu",
"dashboard_skinFreshness": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Ostatni wpis był {count} dzień temu.",
"countPlural=few": "Ostatni wpis był {count} dni temu.",
"countPlural=many": "Ostatni wpis był {count} dni temu.",
"countPlural=*": "Ostatni wpis był {count} dni temu."
}
}
],
"dashboard_daysAgo": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} dzień temu",
"countPlural=few": "{count} dni temu",
"countPlural=many": "{count} dni temu",
"countPlural=*": "{count} dni temu"
}
}
],
"dashboard_daysAgoShort": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} d",
"countPlural=few": "{count} d",
"countPlural=many": "{count} d",
"countPlural=*": "{count} d"
}
}
],
"dashboard_flaggedResults": "wyników oznaczonych flagą",
"dashboard_flaggedLabsCount": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} wynik z flagą",
"countPlural=few": "{count} wyniki z flagą",
"countPlural=many": "{count} wyników z flagą",
"countPlural=*": "{count} wyników z flagą"
}
}
],
"dashboard_attentionSnapshot": "Dziennik skóry",
"dashboard_attentionRoutineAM": "Rutyna AM",
"dashboard_attentionRoutinePM": "Rutyna PM",
"dashboard_attentionLabs": "Przegląd badań",
"dashboard_attentionMissing": "Brak",
"dashboard_attentionToday": "Dzisiaj",
"dashboard_attentionStable": "Brak flagowanych pozycji",
"dashboard_statusLogged": "Zapisane",
"dashboard_statusOpen": "Do uzupełnienia",
"dashboard_metricHydration": "Nawodnienie",
"dashboard_metricSensitivity": "Wrażliwość",
"dashboard_metricSebumTzone": "Sebum T-zone",
"dashboard_metricSebumCheeks": "Sebum policzki",
"dashboard_metricDelta": "Zmiana {delta}",
"dashboard_averageSteps": "Śr. {count} kroków",
"dashboard_lastRoutine": "Ostatnia rutyna",
"dashboard_lastLabDate": "Ostatnie badanie",
"dashboard_noLabResults": "Brak wyników badań.",
"dashboard_noSnapshots": "Brak wpisów o stanie skóry.", "dashboard_noSnapshots": "Brak wpisów o stanie skóry.",
"dashboard_noRoutines": "Brak rutyn w ciągu ostatnich 2 tygodni.", "dashboard_noRoutines": "Brak rutyn w ciągu ostatnich 2 tygodni.",
@ -156,6 +230,9 @@
"grooming_title": "Harmonogram pielęgnacji", "grooming_title": "Harmonogram pielęgnacji",
"grooming_backToRoutines": "Rutyny", "grooming_backToRoutines": "Rutyny",
"grooming_addEntry": "+ Dodaj wpis", "grooming_addEntry": "+ Dodaj wpis",
"grooming_newTitle": "Nowy wpis pielęgnacyjny",
"grooming_newSubtitle": "Dodaj stały wpis do tygodniowego harmonogramu pielęgnacji.",
"grooming_newSectionIntro": "Ustal dzień, czynność i krótką notatkę, jeśli chcesz.",
"grooming_entryAdded": "Wpis dodany.", "grooming_entryAdded": "Wpis dodany.",
"grooming_entryUpdated": "Wpis zaktualizowany.", "grooming_entryUpdated": "Wpis zaktualizowany.",
"grooming_entryDeleted": "Wpis usunięty.", "grooming_entryDeleted": "Wpis usunięty.",
@ -262,6 +339,8 @@
], ],
"medications_addNew": "+ Dodaj lek", "medications_addNew": "+ Dodaj lek",
"medications_newTitle": "Nowy lek", "medications_newTitle": "Nowy lek",
"medications_newSubtitle": "Dodaj podstawowy rekord leku lub suplementu do dalszego śledzenia.",
"medications_newSectionIntro": "Zacznij od rodzaju, nazwy i substancji czynnej.",
"medications_kind": "Rodzaj", "medications_kind": "Rodzaj",
"medications_productName": "Nazwa produktu *", "medications_productName": "Nazwa produktu *",
"medications_productNamePlaceholder": "np. Witamina D3", "medications_productNamePlaceholder": "np. Witamina D3",
@ -303,6 +382,8 @@
], ],
"labResults_addNew": "+ Dodaj wynik", "labResults_addNew": "+ Dodaj wynik",
"labResults_newTitle": "Nowy wynik badania", "labResults_newTitle": "Nowy wynik badania",
"labResults_newSubtitle": "Zapisz pojedynczy wynik badania, aby dołączyć go do historii zdrowia.",
"labResults_newSectionIntro": "Najpierw podaj datę i kod LOINC, resztę możesz uzupełnić skrótowo.",
"labResults_flagFilter": "Flaga:", "labResults_flagFilter": "Flaga:",
"labResults_flagAll": "Wszystkie", "labResults_flagAll": "Wszystkie",
"labResults_flagNone": "Brak", "labResults_flagNone": "Brak",
@ -388,6 +469,8 @@
"skin_analyzePhotos": "Analizuj zdjęcia", "skin_analyzePhotos": "Analizuj zdjęcia",
"skin_analyzing": "Analizuję…", "skin_analyzing": "Analizuję…",
"skin_newSnapshotTitle": "Nowy wpis", "skin_newSnapshotTitle": "Nowy wpis",
"skin_newSubtitle": "Zapisz bieżący stan skóry ręcznie lub uzupełnij pola analizą AI ze zdjęć.",
"skin_newSectionIntro": "Zacznij od daty i ogólnej oceny, a potem doprecyzuj szczegóły.",
"skin_date": "Data *", "skin_date": "Data *",
"skin_overallState": "Ogólny stan", "skin_overallState": "Ogólny stan",
"skin_texture": "Tekstura", "skin_texture": "Tekstura",

View file

@ -153,40 +153,182 @@ body {
} }
.app-mobile-header { .app-mobile-header {
position: sticky;
top: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid hsl(35 22% 76% / 0.7); border-bottom: 1px solid hsl(35 22% 76% / 0.7);
background: linear-gradient(180deg, hsl(44 35% 97%), hsl(44 25% 94%)); background: linear-gradient(180deg, hsl(44 35% 97% / 0.92), hsl(44 25% 94% / 0.96));
backdrop-filter: blur(16px);
padding: 0.9rem 1rem;
}
.app-mobile-titleblock {
min-width: 0;
}
.app-mobile-overline,
.app-sidebar-subtitle {
margin: 0;
color: var(--muted-foreground);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.16em;
line-height: 1.35;
overflow-wrap: anywhere;
text-transform: uppercase;
} }
.app-mobile-title, .app-mobile-title,
.app-brand { .app-brand {
display: block;
font-family: 'Cormorant Infant', 'Times New Roman', serif; font-family: 'Cormorant Infant', 'Times New Roman', serif;
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.app-mobile-toggle,
.app-icon-button { .app-icon-button {
display: flex; display: flex;
height: 2rem; height: 2.6rem;
width: 2rem; width: 2.6rem;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px solid hsl(34 21% 75%); border: 1px solid hsl(34 21% 75% / 0.95);
border-radius: 0.45rem; border-radius: 999px;
background: hsl(42 32% 95% / 0.92);
color: var(--muted-foreground); color: var(--muted-foreground);
box-shadow: 0 10px 24px -20px hsl(220 32% 14% / 0.55);
} }
.app-mobile-toggle:hover,
.app-icon-button:hover { .app-icon-button:hover {
color: var(--foreground); color: var(--foreground);
border-color: var(--page-accent); border-color: var(--page-accent);
background: var(--page-accent-soft); background: var(--page-accent-soft);
} }
.app-drawer-backdrop {
position: fixed;
inset: 0;
z-index: 50;
background: hsl(220 40% 8% / 0.42);
backdrop-filter: blur(4px);
}
.app-sidebar { .app-sidebar {
border-right: 1px solid hsl(36 20% 73% / 0.75); border-right: 1px solid hsl(36 20% 73% / 0.75);
background: linear-gradient(180deg, hsl(44 34% 97%), hsl(42 28% 94%)); background: linear-gradient(180deg, hsl(44 34% 97%), hsl(42 28% 94%));
} }
.app-drawer {
position: fixed;
inset: 0 auto 0 0;
z-index: 60;
display: flex;
width: min(20rem, calc(100vw - 1.5rem));
flex-direction: column;
gap: 1rem;
overflow-y: auto;
border-right: 1px solid hsl(36 20% 73% / 0.75);
background: linear-gradient(180deg, hsl(44 35% 97%), hsl(42 29% 94%));
padding: 1.1rem 0.85rem 1rem;
box-shadow: 0 28px 56px -28px hsl(220 34% 14% / 0.42);
}
.app-sidebar-brandblock {
margin-bottom: 0.8rem;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding: 0 0.8rem;
}
.app-sidebar-brandcopy {
min-width: 0;
max-width: 100%;
}
.app-mobile-header--menu-open {
border-bottom-color: transparent;
background: transparent;
backdrop-filter: none;
}
.app-nav-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.app-nav-link {
display: flex;
align-items: center;
gap: 0.75rem;
border: 1px solid transparent;
border-radius: 0.95rem;
padding: 0.8rem 0.9rem;
color: var(--muted-foreground);
font-size: 0.93rem;
text-decoration: none;
transition: border-color 140ms ease, background-color 140ms ease, color 140ms ease, transform 140ms ease;
}
.app-nav-link:hover {
transform: translateX(2px);
border-color: hsl(35 23% 76% / 0.75);
background: hsl(42 28% 92% / 0.78);
color: var(--foreground);
}
.app-nav-link--active {
border-color: color-mix(in srgb, var(--page-accent) 45%, white);
background: color-mix(in srgb, var(--page-accent) 13%, white);
color: var(--foreground);
box-shadow: inset 0 1px 0 hsl(0 0% 100% / 0.7);
}
.app-sidebar-footer {
margin-top: auto;
padding: 0.8rem;
}
.language-switcher {
display: inline-flex;
width: 100%;
border: 1px solid hsl(35 22% 75% / 0.82);
border-radius: 999px;
background: hsl(42 30% 93% / 0.9);
padding: 0.2rem;
}
.language-switcher__button {
flex: 1;
border-radius: 999px;
padding: 0.45rem 0.7rem;
color: var(--muted-foreground);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
transition: background-color 140ms ease, color 140ms ease, box-shadow 140ms ease;
}
.language-switcher__button:hover {
color: var(--foreground);
}
.language-switcher__button--active {
background: color-mix(in srgb, var(--page-accent) 14%, white);
color: var(--foreground);
box-shadow: inset 0 1px 0 hsl(0 0% 100% / 0.74);
}
.app-sidebar a { .app-sidebar a {
border: 1px solid transparent; border: 1px solid transparent;
} }
@ -212,6 +354,7 @@ body {
width: min(1160px, 100%); width: min(1160px, 100%);
} }
.app-main h1,
.app-main h2 { .app-main h2 {
font-family: 'Cormorant Infant', 'Times New Roman', serif; font-family: 'Cormorant Infant', 'Times New Roman', serif;
font-size: clamp(1.9rem, 3.3vw, 2.7rem); font-size: clamp(1.9rem, 3.3vw, 2.7rem);
@ -241,6 +384,19 @@ body {
color: var(--foreground); color: var(--foreground);
} }
.page-header-meta,
.page-header-foot {
grid-column: 1 / -1;
}
.page-header-meta {
margin-top: 0.65rem;
}
.page-header-foot {
margin-top: 1rem;
}
.editorial-toolbar { .editorial-toolbar {
margin-top: 0.9rem; margin-top: 0.9rem;
display: flex; display: flex;
@ -289,7 +445,7 @@ body {
color: hsl(207 78% 28%); color: hsl(207 78% 28%);
} }
.products-table-shell { .editorial-table-shell {
border: 1px solid hsl(35 24% 74% / 0.85); border: 1px solid hsl(35 24% 74% / 0.85);
border-radius: 0.9rem; border-radius: 0.9rem;
overflow: hidden; overflow: hidden;
@ -299,14 +455,14 @@ body {
background: color-mix(in srgb, var(--page-accent) 10%, white); background: color-mix(in srgb, var(--page-accent) 10%, white);
} }
.products-mobile-card { .editorial-mobile-card {
display: block; display: block;
border: 1px solid hsl(35 21% 76% / 0.85); border: 1px solid hsl(35 21% 76% / 0.85);
border-radius: 0.8rem; border-radius: 0.8rem;
padding: 0.95rem; padding: 0.95rem;
} }
.products-section-title { .editorial-section-title {
border-bottom: 1px dashed color-mix(in srgb, var(--page-accent) 35%, var(--border)); border-bottom: 1px dashed color-mix(in srgb, var(--page-accent) 35%, var(--border));
padding-bottom: 0.3rem; padding-bottom: 0.3rem;
padding-top: 0.5rem; padding-top: 0.5rem;
@ -317,11 +473,7 @@ body {
text-transform: uppercase; text-transform: uppercase;
} }
.products-sticky-actions { .editorial-meta-strip {
border-color: color-mix(in srgb, var(--page-accent) 25%, var(--border));
}
.products-meta-strip {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
@ -488,7 +640,7 @@ body {
font-feature-settings: 'tnum'; font-feature-settings: 'tnum';
} }
.lab-results-mobile-grid .products-section-title { .lab-results-mobile-grid .editorial-section-title {
margin-top: 0.15rem; margin-top: 0.15rem;
} }
@ -525,6 +677,20 @@ body {
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.app-mobile-header,
.app-drawer,
.app-drawer-backdrop {
display: none !important;
}
.app-sidebar {
position: sticky;
top: 0;
align-self: flex-start;
height: 100vh;
overflow-y: auto;
}
.app-shell { .app-shell {
flex-direction: row; flex-direction: row;
} }
@ -556,6 +722,10 @@ body {
grid-area: subtitle; grid-area: subtitle;
} }
.dashboard-stat-strip {
grid-column: 1 / -1;
}
.editorial-toolbar { .editorial-toolbar {
grid-area: actions; grid-area: actions;
margin-top: 0; margin-top: 0;
@ -661,6 +831,122 @@ body {
font-weight: 600; font-weight: 600;
} }
.dashboard-stat-strip {
margin-top: 1.8rem;
display: grid;
gap: 0.65rem;
grid-template-columns: repeat(4, minmax(0, 1fr));
border-top: 1px dashed color-mix(in srgb, var(--page-accent) 24%, var(--editorial-line));
padding-top: 1rem;
}
.dashboard-stat-card {
border: 1px solid hsl(36 18% 77% / 0.62);
border-radius: 0.85rem;
background: linear-gradient(180deg, hsl(44 32% 96% / 0.74), hsl(45 24% 93% / 0.66));
padding: 0.72rem 0.78rem;
}
.dashboard-stat-label,
.dashboard-metric-label,
.dashboard-featured-label,
.dashboard-health-subline,
.dashboard-attention-label {
margin: 0;
color: var(--editorial-muted);
font-size: 0.73rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.dashboard-stat-value,
.dashboard-metric-value,
.dashboard-featured-value {
margin: 0.28rem 0 0;
font-family: 'Cormorant Infant', 'Times New Roman', serif;
font-size: 1.28rem;
font-weight: 600;
line-height: 1;
}
.dashboard-stat-detail,
.dashboard-featured-meta,
.dashboard-featured-notes,
.dashboard-health-value,
.dashboard-attention-value,
.dashboard-panel-note,
.dashboard-metric-trend {
margin: 0.28rem 0 0;
color: var(--editorial-muted);
font-size: 0.8rem;
}
.dashboard-attention-panel {
position: relative;
z-index: 1;
margin-bottom: 1rem;
border: 1px solid hsl(36 25% 74% / 0.8);
border-radius: 1.2rem;
background: linear-gradient(180deg, hsl(44 40% 95% / 0.92), hsl(42 32% 93% / 0.94));
box-shadow:
0 20px 40px -34px hsl(219 32% 14% / 0.38),
inset 0 1px 0 hsl(0 0% 100% / 0.7);
padding: 0.9rem;
}
.dashboard-attention-header {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.85rem;
}
.dashboard-attention-header h3 {
margin: 0;
font-family: 'Cormorant Infant', 'Times New Roman', serif;
font-size: clamp(1.3rem, 2.3vw, 1.6rem);
font-weight: 600;
}
.dashboard-attention-list {
display: grid;
gap: 0.6rem;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.dashboard-attention-item {
display: flex;
min-height: 4.35rem;
flex-direction: column;
justify-content: space-between;
border: 1px solid hsl(36 22% 74% / 0.75);
border-radius: 0.9rem;
padding: 0.72rem 0.8rem;
text-decoration: none;
color: inherit;
transition: transform 140ms ease, border-color 140ms ease, background-color 140ms ease;
}
.dashboard-attention-item:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--page-accent) 42%, var(--border));
}
.dashboard-attention-item--alert {
background: linear-gradient(180deg, hsl(27 76% 95%), hsl(18 60% 91%));
}
.dashboard-attention-item--calm {
background: linear-gradient(180deg, hsl(130 24% 95%), hsl(136 26% 91%));
}
.dashboard-attention-value {
color: var(--foreground);
font-size: 0.88rem;
font-weight: 600;
}
.editorial-grid { .editorial-grid {
position: relative; position: relative;
z-index: 1; z-index: 1;
@ -671,11 +957,16 @@ body {
.editorial-panel { .editorial-panel {
border-radius: 1.2rem; border-radius: 1.2rem;
padding: 1rem; padding: 0.9rem;
}
.dashboard-grid {
grid-template-columns: minmax(0, 1.08fr) minmax(0, 1fr) minmax(0, 0.92fr);
align-items: start;
} }
.panel-header { .panel-header {
margin-bottom: 0.9rem; margin-bottom: 0.75rem;
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: space-between; justify-content: space-between;
@ -687,7 +978,7 @@ body {
.panel-header h3 { .panel-header h3 {
margin: 0; margin: 0;
font-family: 'Cormorant Infant', 'Times New Roman', serif; font-family: 'Cormorant Infant', 'Times New Roman', serif;
font-size: clamp(1.35rem, 2.4vw, 1.7rem); font-size: clamp(1.22rem, 2.1vw, 1.56rem);
font-weight: 600; font-weight: 600;
} }
@ -714,7 +1005,7 @@ body {
.snapshot-date { .snapshot-date {
color: var(--editorial-muted); color: var(--editorial-muted);
font-size: 0.9rem; font-size: 0.84rem;
font-weight: 600; font-weight: 600;
} }
@ -786,33 +1077,51 @@ body {
list-style: none; list-style: none;
} }
.dashboard-metric-row {
margin-top: 0.85rem;
display: grid;
gap: 0.55rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dashboard-metric-card {
border: 1px solid hsl(36 23% 74% / 0.76);
border-radius: 0.8rem;
background: hsl(42 36% 93% / 0.7);
padding: 0.65rem 0.72rem;
}
.dashboard-metric-value {
font-size: 1.08rem;
}
.routine-summary-strip { .routine-summary-strip {
margin-bottom: 0.7rem; margin-bottom: 0.6rem;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.35rem;
} }
.routine-summary-chip { .routine-summary-chip {
border: 1px solid hsl(35 24% 71% / 0.85); border: 1px solid hsl(35 24% 71% / 0.85);
border-radius: 999px; border-radius: 999px;
padding: 0.22rem 0.62rem; padding: 0.16rem 0.5rem;
color: var(--editorial-muted); color: var(--editorial-muted);
font-size: 0.74rem; font-size: 0.68rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.08em; letter-spacing: 0.06em;
} }
.panel-action-link, .panel-action-link,
.routine-summary-link { .routine-summary-link {
border: 1px solid color-mix(in srgb, var(--page-accent) 38%, var(--editorial-line)); border: 1px solid color-mix(in srgb, var(--page-accent) 38%, var(--editorial-line));
border-radius: 999px; border-radius: 999px;
padding: 0.24rem 0.64rem; padding: 0.2rem 0.56rem;
color: var(--page-accent); color: var(--page-accent);
font-size: 0.76rem; font-size: 0.68rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.08em; letter-spacing: 0.06em;
text-decoration: none; text-decoration: none;
text-transform: uppercase; text-transform: uppercase;
} }
@ -826,6 +1135,32 @@ body {
background: var(--page-accent-soft); background: var(--page-accent-soft);
} }
.dashboard-featured-routine {
display: block;
margin-bottom: 0.7rem;
border: 1px solid hsl(36 24% 73% / 0.82);
border-radius: 0.88rem;
background: linear-gradient(155deg, color-mix(in srgb, var(--page-accent) 5%, white), hsl(44 30% 94%));
padding: 0.78rem 0.84rem;
text-decoration: none;
color: inherit;
}
.dashboard-featured-routine:hover {
border-color: color-mix(in srgb, var(--page-accent) 44%, var(--border));
}
.dashboard-featured-routine-topline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.dashboard-featured-notes {
line-height: 1.4;
}
.routine-item + .routine-item { .routine-item + .routine-item {
border-top: 1px dashed hsl(36 26% 72% / 0.7); border-top: 1px dashed hsl(36 26% 72% / 0.7);
} }
@ -834,7 +1169,7 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.6rem;
padding: 0.78rem 0; padding: 0.68rem 0;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
transition: transform 140ms ease, color 160ms ease; transition: transform 140ms ease, color 160ms ease;
@ -858,9 +1193,9 @@ body {
.routine-meta { .routine-meta {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.75rem; gap: 0.6rem;
color: var(--editorial-muted); color: var(--editorial-muted);
font-size: 0.8rem; font-size: 0.76rem;
} }
.routine-note-inline { .routine-note-inline {
@ -882,7 +1217,7 @@ body {
} }
.routine-date { .routine-date {
font-size: 0.93rem; font-size: 0.88rem;
font-weight: 600; font-weight: 600;
} }
@ -909,6 +1244,56 @@ body {
display: flex; display: flex;
} }
.dashboard-health-meta-strip {
margin-bottom: 0.65rem;
}
.dashboard-health-list {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.dashboard-health-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.65rem;
border: 1px solid hsl(36 22% 75% / 0.78);
border-radius: 0.85rem;
background: hsl(44 34% 95% / 0.75);
padding: 0.68rem 0.78rem;
text-decoration: none;
color: inherit;
}
.dashboard-health-item:hover {
border-color: color-mix(in srgb, var(--page-accent) 36%, var(--border));
background: var(--page-accent-soft);
}
.dashboard-health-test {
margin: 0;
font-size: 0.88rem;
font-weight: 600;
}
.dashboard-health-value-wrap {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-end;
gap: 0.35rem;
}
.dashboard-health-value {
margin-top: 0;
color: var(--foreground);
font-size: 0.8rem;
font-weight: 600;
text-align: right;
}
.reveal-1, .reveal-1,
.reveal-2, .reveal-2,
.reveal-3 { .reveal-3 {
@ -933,12 +1318,23 @@ body {
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.dashboard-stat-strip,
.dashboard-attention-list,
.editorial-grid { .editorial-grid {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.dashboard-grid {
grid-template-columns: minmax(0, 1fr);
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.dashboard-stat-strip {
margin-top: 1.35rem;
padding-top: 0.8rem;
}
.editorial-title { .editorial-title {
font-size: 2.05rem; font-size: 2.05rem;
} }
@ -951,6 +1347,21 @@ body {
font-size: 1.4rem; font-size: 1.4rem;
} }
.dashboard-metric-row {
grid-template-columns: minmax(0, 1fr);
}
.dashboard-health-item,
.dashboard-featured-routine-topline,
.dashboard-attention-item {
align-items: flex-start;
flex-direction: column;
}
.dashboard-health-value-wrap {
align-items: flex-start;
}
.state-pill, .state-pill,
.routine-pill { .routine-pill {
letter-spacing: 0.08em; letter-spacing: 0.08em;

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Sparkles } from 'lucide-svelte'; import { Sparkles } from 'lucide-svelte';
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
interface Props { interface Props {
autoFixes: string[]; autoFixes: string[];

View file

@ -0,0 +1,18 @@
<script lang="ts">
type FlashMessage = {
kind: 'error' | 'success' | 'warning' | 'info';
text: string;
};
let { messages = [] }: { messages?: FlashMessage[] } = $props();
</script>
{#if messages.length}
<div class="space-y-2">
{#each messages as message, index (`${message.kind}-${index}-${message.text}`)}
<div class={`editorial-alert editorial-alert--${message.kind}`}>
{message.text}
</div>
{/each}
</div>
{/if}

View file

@ -1,15 +1,21 @@
<script lang="ts"> <script lang="ts">
import { getLocale, setLocale } from '$lib/paraglide/runtime.js'; import * as runtime from '$lib/paraglide/runtime.js';
const locales = [
{ code: 'pl', label: 'PL' },
{ code: 'en', label: 'EN' }
] as const;
</script> </script>
<div class="flex gap-1 font-mono text-xs text-muted-foreground"> <div class="language-switcher" role="group" aria-label="Language switcher">
{#each locales as locale (locale.code)}
<button <button
class="hover:text-foreground transition-colors {getLocale() === 'pl' ? 'font-bold text-foreground' : ''}" type="button"
onclick={() => setLocale('pl')} class:language-switcher__button={true}
>PL</button> class:language-switcher__button--active={runtime.getLocale() === locale.code}
<span>|</span> onclick={() => runtime.setLocale(locale.code)}
<button >
class="hover:text-foreground transition-colors {getLocale() === 'en' ? 'font-bold text-foreground' : ''}" {locale.label}
onclick={() => setLocale('en')} </button>
>EN</button> {/each}
</div> </div>

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Info, ChevronDown, ChevronRight } from 'lucide-svelte'; import { Info, ChevronDown, ChevronRight } from 'lucide-svelte';
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import type { ResponseMetadata } from '$lib/types'; import type { ResponseMetadata } from '$lib/types';
interface Props { interface Props {

View file

@ -0,0 +1,62 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { ArrowLeft } from 'lucide-svelte';
let {
title,
kicker,
subtitle,
backHref,
backLabel,
className = '',
titleClassName = 'editorial-title',
meta,
actions,
children
}: {
title: string;
kicker?: string;
subtitle?: string;
backHref?: string;
backLabel?: string;
className?: string;
titleClassName?: string;
meta?: Snippet;
actions?: Snippet;
children?: Snippet;
} = $props();
</script>
<section class={`editorial-hero reveal-1 ${className}`.trim()}>
{#if backHref && backLabel}
<a href={backHref} class="editorial-backlink"><ArrowLeft class="size-4" /> {backLabel}</a>
{/if}
{#if kicker}
<p class="editorial-kicker">{kicker}</p>
{/if}
<h1 class={titleClassName}>{title}</h1>
{#if subtitle}
<p class="editorial-subtitle">{subtitle}</p>
{/if}
{#if meta}
<div class="page-header-meta">
{@render meta()}
</div>
{/if}
{#if actions}
<div class="editorial-toolbar">
{@render actions()}
</div>
{/if}
{#if children}
<div class="page-header-foot">
{@render children()}
</div>
{/if}
</section>

View file

@ -9,7 +9,7 @@
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { baseSelectClass, baseTextareaClass } from '$lib/components/forms/form-classes'; import { baseSelectClass, baseTextareaClass } from '$lib/components/forms/form-classes';
import type { ProductParseResponse } from '$lib/api'; import type { ProductParseResponse } from '$lib/api';
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { Sparkles, X } from 'lucide-svelte'; import { Sparkles, X } from 'lucide-svelte';
let { let {

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Sparkles, X } from 'lucide-svelte'; import { Sparkles, X } from 'lucide-svelte';

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Brain, ChevronDown, ChevronRight } from 'lucide-svelte'; import { Brain, ChevronDown, ChevronRight } from 'lucide-svelte';
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
interface Props { interface Props {
reasoningChain?: string; reasoningChain?: string;

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { XCircle } from 'lucide-svelte'; import { XCircle } from 'lucide-svelte';
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
interface Props { interface Props {
error: string; error: string;

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { AlertTriangle } from 'lucide-svelte'; import { AlertTriangle } from 'lucide-svelte';
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
interface Props { interface Props {
warnings: string[]; warnings: string[];

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';

View file

@ -0,0 +1,5 @@
export function preventIfNotConfirmed(event: SubmitEvent, message: string) {
if (!confirm(message)) {
event.preventDefault();
}
}

View file

@ -0,0 +1,57 @@
import {
common_unknown,
productForm_concernAcne,
productForm_concernAging,
productForm_concernDamagedBarrier,
productForm_concernDehydration,
productForm_concernHairGrowth,
productForm_concernHyperpigmentation,
productForm_concernPoreVisibility,
productForm_concernRedness,
productForm_concernRosacea,
productForm_concernSebumExcess,
productForm_concernUnevenTexture,
skin_stateExcellent,
skin_stateFair,
skin_stateGood,
skin_statePoor
} from '$lib/paraglide/messages.js';
import type { OverallSkinState, SkinConcern } from '$lib/types';
export function humanizeUnderscore(text: string): string {
return text
.split('_')
.map((chunk) => chunk.charAt(0).toUpperCase() + chunk.slice(1))
.join(' ');
}
export function getOverallSkinStateLabel(state?: OverallSkinState | null): string {
if (!state) return common_unknown();
const labels: Record<OverallSkinState, () => string> = {
excellent: skin_stateExcellent,
good: skin_stateGood,
fair: skin_stateFair,
poor: skin_statePoor
};
return labels[state]?.() ?? humanizeUnderscore(state);
}
export function getSkinConcernLabel(concern: SkinConcern | string): string {
const labels: Partial<Record<SkinConcern, () => string>> = {
acne: productForm_concernAcne,
rosacea: productForm_concernRosacea,
hyperpigmentation: productForm_concernHyperpigmentation,
aging: productForm_concernAging,
dehydration: productForm_concernDehydration,
redness: productForm_concernRedness,
damaged_barrier: productForm_concernDamagedBarrier,
pore_visibility: productForm_concernPoreVisibility,
uneven_texture: productForm_concernUnevenTexture,
hair_growth: productForm_concernHairGrowth,
sebum_excess: productForm_concernSebumExcess
};
return labels[concern as SkinConcern]?.() ?? humanizeUnderscore(concern);
}

View file

@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import { afterNavigate } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte'; import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
import { import {
House, House,
@ -22,6 +23,10 @@
let mobileMenuOpen = $state(false); let mobileMenuOpen = $state(false);
const domainClass = $derived(getDomainClass(page.url.pathname)); const domainClass = $derived(getDomainClass(page.url.pathname));
afterNavigate(() => {
mobileMenuOpen = false;
});
const navItems = $derived([ const navItems = $derived([
{ href: resolve('/'), label: m.nav_dashboard(), icon: House }, { href: resolve('/'), label: m.nav_dashboard(), icon: House },
{ href: resolve('/routines'), label: m.nav_routines(), icon: ClipboardList }, { href: resolve('/routines'), label: m.nav_routines(), icon: ClipboardList },
@ -56,16 +61,17 @@
</script> </script>
<div class="app-shell {domainClass}"> <div class="app-shell {domainClass}">
<!-- Mobile header --> <header class={`app-mobile-header md:hidden ${mobileMenuOpen ? 'app-mobile-header--menu-open' : ''}`}>
<header class="app-mobile-header md:hidden"> <div class="app-mobile-titleblock">
<div> <p class="app-mobile-overline">{m["nav_appSubtitle"]()}</p>
<span class="app-mobile-title">{m["nav_appName"]()}</span> <span class="app-mobile-title">{m["nav_appName"]()}</span>
</div> </div>
<button <button
type="button" type="button"
onclick={() => (mobileMenuOpen = !mobileMenuOpen)} onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
class="app-icon-button" class="app-mobile-toggle"
aria-label={m.common_toggleMenu()} aria-label={m.common_toggleMenu()}
aria-expanded={mobileMenuOpen}
> >
{#if mobileMenuOpen} {#if mobileMenuOpen}
<X class="size-[18px]" /> <X class="size-[18px]" />
@ -75,74 +81,73 @@
</button> </button>
</header> </header>
<!-- Mobile drawer overlay -->
{#if mobileMenuOpen} {#if mobileMenuOpen}
<!-- Backdrop: closes drawer on click -->
<button <button
type="button" type="button"
class="fixed inset-0 z-50 bg-black/50 md:hidden" class="app-drawer-backdrop md:hidden"
onclick={() => (mobileMenuOpen = false)} onclick={() => (mobileMenuOpen = false)}
aria-label={m.common_cancel()} aria-label={m.common_cancel()}
></button> ></button>
<!-- Drawer (same z-50 but later in DOM, on top) --> <aside class="app-drawer md:hidden" aria-label={m.common_toggleMenu()}>
<nav <div class="app-sidebar-brandblock">
class="fixed inset-y-0 left-0 z-50 w-64 overflow-y-auto bg-card px-3 py-6 md:hidden app-sidebar" <div class="app-sidebar-brandcopy">
>
<div class="mb-8 px-3">
<h1 class="app-brand">{m["nav_appName"]()}</h1> <h1 class="app-brand">{m["nav_appName"]()}</h1>
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p> <p class="app-sidebar-subtitle">{m["nav_appSubtitle"]()}</p>
</div> </div>
<ul class="space-y-1"> <button
type="button"
onclick={() => (mobileMenuOpen = false)}
class="app-icon-button"
aria-label={m.common_cancel()}
>
<X class="size-[18px]" />
</button>
</div>
<ul class="app-nav-list">
{#each navItems as item (item.href)} {#each navItems as item (item.href)}
<li> <li>
<a <a
href={item.href} href={item.href}
onclick={() => (mobileMenuOpen = false)} onclick={() => (mobileMenuOpen = false)}
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors class={`app-nav-link ${isActive(item.href) ? 'app-nav-link--active' : ''}`}
{isActive(item.href)
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
> >
<item.icon class="size-4 shrink-0" /> <item.icon class="size-4 shrink-0" />
{item.label} <span>{item.label}</span>
</a> </a>
</li> </li>
{/each} {/each}
</ul> </ul>
<div class="mt-6 px-3"> <div class="app-sidebar-footer">
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
</nav> </aside>
{/if} {/if}
<!-- Desktop Sidebar -->
<nav class="app-sidebar hidden w-56 shrink-0 flex-col px-3 py-6 md:flex"> <nav class="app-sidebar hidden w-56 shrink-0 flex-col px-3 py-6 md:flex">
<div class="mb-8 px-3"> <div class="app-sidebar-brandblock">
<div class="app-sidebar-brandcopy">
<h1 class="app-brand">{m["nav_appName"]()}</h1> <h1 class="app-brand">{m["nav_appName"]()}</h1>
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p> <p class="app-sidebar-subtitle">{m["nav_appSubtitle"]()}</p>
</div> </div>
<ul class="space-y-1"> </div>
<ul class="app-nav-list">
{#each navItems as item (item.href)} {#each navItems as item (item.href)}
<li> <li>
<a <a
href={item.href} href={item.href}
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors class={`app-nav-link ${isActive(item.href) ? 'app-nav-link--active' : ''}`}
{isActive(item.href)
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
> >
<item.icon class="size-4 shrink-0" /> <item.icon class="size-4 shrink-0" />
{item.label} <span>{item.label}</span>
</a> </a>
</li> </li>
{/each} {/each}
</ul> </ul>
<div class="mt-6 px-3"> <div class="app-sidebar-footer">
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
</nav> </nav>
<!-- Main content -->
<main class="app-main"> <main class="app-main">
{@render children()} {@render children()}
</main> </main>

View file

@ -1,15 +1,18 @@
import { getRoutines, getSkinSnapshots } from '$lib/api'; import { getLabResults, getRoutines, getSkinSnapshots } from '$lib/api';
import type { SkinConditionSnapshot } from '$lib/types'; import type { Routine, SkinConditionSnapshot } from '$lib/types';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
const [routines, snapshots] = await Promise.all([ const [routines, snapshots, labResults] = await Promise.all([
getRoutines({ from_date: recentDate(14) }), getRoutines({ from_date: recentDate(14) }),
getSkinSnapshots({ from_date: recentDate(60) }) getSkinSnapshots({ from_date: recentDate(60) }),
getLabResults({ latest_only: true, limit: 8 })
]); ]);
return { return {
recentRoutines: routines.slice(0, 10), recentRoutines: getFreshestRoutines(routines).slice(0, 10),
latestSnapshot: getFreshestSnapshot(snapshots) recentSnapshots: snapshots,
latestSnapshot: getFreshestSnapshot(snapshots),
recentLabResults: labResults.items
}; };
}; };
@ -27,3 +30,11 @@ function getFreshestSnapshot(snapshots: SkinConditionSnapshot[]): SkinConditionS
return current.created_at > freshest.created_at ? current : freshest; return current.created_at > freshest.created_at ? current : freshest;
}); });
} }
function getFreshestRoutines(routines: Routine[]): Routine[] {
return [...routines].sort((a, b) => {
if (a.routine_date !== b.routine_date) return b.routine_date.localeCompare(a.routine_date);
if (a.part_of_day !== b.part_of_day) return b.part_of_day.localeCompare(a.part_of_day);
return b.created_at.localeCompare(a.created_at);
});
}

View file

@ -1,7 +1,56 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import {
common_am,
common_pm,
common_steps,
dashboard_attentionLabs,
dashboard_attentionMissing,
dashboard_attentionRoutineAM,
dashboard_attentionRoutinePM,
dashboard_attentionSnapshot,
dashboard_attentionStable,
dashboard_attentionToday,
dashboard_averageSteps,
dashboard_dailyBriefing,
dashboard_daysAgo,
dashboard_daysAgoShort,
dashboard_flaggedLabsCount,
dashboard_flaggedResults,
dashboard_healthPulse,
dashboard_heroFreshness,
dashboard_lastLabDate,
dashboard_lastRoutine,
dashboard_latestSnapshot,
dashboard_metricDelta,
dashboard_metricHydration,
dashboard_metricSebumCheeks,
dashboard_metricSebumTzone,
dashboard_metricSensitivity,
dashboard_noLabResults,
dashboard_noRoutines,
dashboard_noSnapshots,
dashboard_recentRoutines,
dashboard_requiresAttention,
dashboard_sinceLastSnapshot,
dashboard_skinFreshness,
dashboard_statusLogged,
dashboard_statusOpen,
dashboard_title,
dashboard_viewLabResults,
dashboard_viewSkinHistory,
labResults_boolFalse,
labResults_boolTrue,
labResults_valueEmpty,
nav_appSubtitle,
routines_addNew,
routines_notes,
skin_activeConcerns,
skin_addNew
} from '$lib/paraglide/messages.js';
import type { LabResult, OverallSkinState, SkinConditionSnapshot } from '$lib/types';
import { getOverallSkinStateLabel, getSkinConcernLabel } from '$lib/utils/skin-display';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { m } from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@ -17,95 +66,306 @@
pm: 'routine-pill routine-pill--pm' pm: 'routine-pill routine-pill--pm'
}; };
function humanize(text: string): string { function daysSince(dateString: string): number {
return text const todayStart = Date.parse(new Date().toISOString().slice(0, 10));
.split('_') const dateStart = Date.parse(`${dateString}T00:00:00`);
.map((chunk) => chunk.charAt(0).toUpperCase() + chunk.slice(1)) const diffMs = todayStart - dateStart;
.join(' '); return Math.max(0, Math.round(diffMs / 86_400_000));
} }
const routineStats = $derived.by(() => { function formatState(state?: OverallSkinState | null): string {
const amCount = data.recentRoutines.filter((routine) => routine.part_of_day === 'am').length; return getOverallSkinStateLabel(state);
const pmCount = data.recentRoutines.length - amCount; }
return { amCount, pmCount };
function formatConcern(concern: string): string {
return getSkinConcernLabel(concern);
}
function formatLabLabel(lab: LabResult): string {
return lab.test_name_original ?? lab.test_name_loinc ?? lab.test_code;
}
function formatLabValue(lab: LabResult): string {
if (lab.value_num != null) {
return `${lab.value_num}${lab.unit_original ? ` ${lab.unit_original}` : ''}`;
}
if (lab.value_text) return lab.value_text;
if (lab.value_bool != null) return lab.value_bool ? labResults_boolTrue() : labResults_boolFalse();
return labResults_valueEmpty();
}
const metricLabels = {
hydration_level: dashboard_metricHydration,
sensitivity_level: dashboard_metricSensitivity,
sebum_tzone: dashboard_metricSebumTzone,
sebum_cheeks: dashboard_metricSebumCheeks
} as const;
const sortedRoutines = $derived.by(() => {
return [...data.recentRoutines].sort((a, b) => {
if (a.routine_date !== b.routine_date) return b.routine_date.localeCompare(a.routine_date);
return b.created_at.localeCompare(a.created_at);
}); });
});
const sortedSnapshots = $derived.by(() => {
return [...(data.recentSnapshots ?? [])].sort((a, b) => {
if (a.snapshot_date !== b.snapshot_date) return b.snapshot_date.localeCompare(a.snapshot_date);
return b.created_at.localeCompare(a.created_at);
});
});
const sortedLabResults = $derived.by(() => {
return [...(data.recentLabResults ?? [])].sort((a, b) => {
if (a.collected_at !== b.collected_at) return b.collected_at.localeCompare(a.collected_at);
return b.created_at.localeCompare(a.created_at);
});
});
const routineStats = $derived.by(() => {
const amCount = sortedRoutines.filter((routine) => routine.part_of_day === 'am').length;
const pmCount = sortedRoutines.length - amCount;
const totalSteps = sortedRoutines.reduce((sum, routine) => sum + (routine.steps?.length ?? 0), 0);
const averageSteps = sortedRoutines.length ? Math.round((totalSteps / sortedRoutines.length) * 10) / 10 : 0;
return { amCount, pmCount, averageSteps };
});
const latestRoutine = $derived(sortedRoutines[0] ?? null);
const latestSnapshot = $derived(data.latestSnapshot ?? sortedSnapshots[0] ?? null);
const previousSnapshot = $derived(
latestSnapshot ? sortedSnapshots.find((snapshot) => snapshot.id !== latestSnapshot.id) ?? null : null
);
const todayIso = $derived(new Date().toISOString().slice(0, 10));
const hasRoutineTodayAM = $derived(
sortedRoutines.some((routine) => routine.routine_date === todayIso && routine.part_of_day === 'am')
);
const hasRoutineTodayPM = $derived(
sortedRoutines.some((routine) => routine.routine_date === todayIso && routine.part_of_day === 'pm')
);
const daysSinceSnapshot = $derived(latestSnapshot ? daysSince(latestSnapshot.snapshot_date) : null);
const snapshotMetrics = $derived.by(() => {
if (!latestSnapshot) return [] as Array<{ label: string; value: string; trend?: string }>;
const metrics = Object.entries(metricLabels) as Array<[
keyof typeof metricLabels,
() => string
]>;
return metrics
.map(([key, label]) => {
const current = latestSnapshot[key as keyof SkinConditionSnapshot];
const previous = previousSnapshot?.[key as keyof SkinConditionSnapshot];
if (typeof current !== 'number') return null;
let trend: string | undefined;
if (typeof previous === 'number') {
const delta = current - previous;
if (delta > 0) trend = `+${delta}`;
if (delta < 0) trend = `${delta}`;
if (delta === 0) trend = '0';
}
return {
label: label(),
value: `${current}/5`,
trend
};
})
.filter((metric) => metric !== null);
});
const notableFlags = new Set(['ABN', 'H', 'L', 'POS']);
const flaggedLabs = $derived(sortedLabResults.filter((lab) => lab.flag && notableFlags.has(lab.flag)));
const featuredLabs = $derived((flaggedLabs.length ? flaggedLabs : sortedLabResults).slice(0, 4));
const latestLabDate = $derived(sortedLabResults[0]?.collected_at.slice(0, 10) ?? null);
type AttentionRoute = '/skin' | '/routines/new' | '/health/lab-results';
const attentionItems = $derived.by(() => {
return [
{
label: dashboard_attentionSnapshot(),
value:
daysSinceSnapshot == null
? dashboard_attentionMissing()
: daysSinceSnapshot === 0
? dashboard_attentionToday()
: dashboard_daysAgo({ count: daysSinceSnapshot }),
route: '/skin',
tone: daysSinceSnapshot == null || daysSinceSnapshot > 7 ? 'alert' : 'calm'
},
{
label: dashboard_attentionRoutineAM(),
value: hasRoutineTodayAM ? dashboard_statusLogged() : dashboard_statusOpen(),
route: '/routines/new',
tone: hasRoutineTodayAM ? 'calm' : 'alert'
},
{
label: dashboard_attentionRoutinePM(),
value: hasRoutineTodayPM ? dashboard_statusLogged() : dashboard_statusOpen(),
route: '/routines/new',
tone: hasRoutineTodayPM ? 'calm' : 'alert'
},
{
label: dashboard_attentionLabs(),
value: flaggedLabs.length
? dashboard_flaggedLabsCount({ count: flaggedLabs.length })
: dashboard_attentionStable(),
route: '/health/lab-results',
tone: flaggedLabs.length ? 'alert' : 'calm'
}
] as Array<{ label: string; value: string; route: AttentionRoute; tone: 'alert' | 'calm' }>;
});
const heroStats = $derived.by(() => [
{
label: dashboard_latestSnapshot(),
value: latestSnapshot ? latestSnapshot.snapshot_date : dashboard_attentionMissing(),
detail: latestSnapshot ? formatState(latestSnapshot.overall_state) : dashboard_noSnapshots()
},
{
label: dashboard_recentRoutines(),
value: String(sortedRoutines.length),
detail: `${common_am()} ${routineStats.amCount} - ${common_pm()} ${routineStats.pmCount}`
},
{
label: dashboard_heroFreshness(),
value: daysSinceSnapshot == null ? '-' : dashboard_daysAgoShort({ count: daysSinceSnapshot }),
detail: latestSnapshot ? dashboard_sinceLastSnapshot() : dashboard_noSnapshots()
},
{
label: dashboard_healthPulse(),
value: String(flaggedLabs.length),
detail: flaggedLabs.length ? dashboard_flaggedResults() : dashboard_attentionStable()
}
]);
</script> </script>
<svelte:head><title>{m.dashboard_title()} — innercontext</title></svelte:head> <svelte:head><title>{dashboard_title()} - innercontext</title></svelte:head>
<div class="editorial-dashboard"> <div class="editorial-dashboard">
<div class="editorial-atmosphere" aria-hidden="true"></div> <div class="editorial-atmosphere" aria-hidden="true"></div>
<section class="editorial-hero reveal-1"> <section class="editorial-hero reveal-1">
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> <p class="editorial-kicker">{nav_appSubtitle()}</p>
<h2 class="editorial-title">{m.dashboard_title()}</h2> <h1 class="editorial-title">{dashboard_title()}</h1>
<p class="editorial-subtitle">{m.dashboard_subtitle()}</p> <p class="editorial-subtitle">{dashboard_dailyBriefing()}</p>
{#if data.latestSnapshot} <div class="dashboard-stat-strip" aria-label={dashboard_dailyBriefing()}>
{@const snapshot = data.latestSnapshot} {#each heroStats as stat (stat.label)}
<div class="hero-strip"> <div class="dashboard-stat-card">
<div> <p class="dashboard-stat-label">{stat.label}</p>
<p class="hero-strip-label">{m["dashboard_latestSnapshot"]()}</p> <p class="dashboard-stat-value">{stat.value}</p>
<p class="hero-strip-value">{snapshot.snapshot_date}</p> <p class="dashboard-stat-detail">{stat.detail}</p>
</div> </div>
{#if snapshot.overall_state} {/each}
<span class={stateTone[snapshot.overall_state] ?? 'state-pill'}> </div>
{humanize(snapshot.overall_state)} </section>
<section class="dashboard-attention-panel reveal-2" aria-labelledby="dashboard-attention-title">
<div class="dashboard-attention-header">
<p class="panel-index">01</p>
<h3 id="dashboard-attention-title">{dashboard_requiresAttention()}</h3>
</div>
<div class="dashboard-attention-list">
{#each attentionItems as item (item.label)}
<a href={resolve(item.route)} class={`dashboard-attention-item dashboard-attention-item--${item.tone}`}>
<span class="dashboard-attention-label">{item.label}</span>
<span class="dashboard-attention-value">{item.value}</span>
</a>
{/each}
</div>
</section>
<div class="editorial-grid dashboard-grid">
<section class="editorial-panel reveal-2">
<header class="panel-header">
<p class="panel-index">02</p>
<h3>{dashboard_latestSnapshot()}</h3>
</header>
<div class="panel-action-row">
<a href={resolve('/skin')} class="panel-action-link">{dashboard_viewSkinHistory()}</a>
</div>
{#if latestSnapshot}
<div class="snapshot-meta-row">
<span class="snapshot-date">{latestSnapshot.snapshot_date}</span>
{#if latestSnapshot.overall_state}
<span class={stateTone[latestSnapshot.overall_state] ?? 'state-pill'}>
{formatState(latestSnapshot.overall_state)}
</span> </span>
{/if} {/if}
</div> </div>
{#if daysSinceSnapshot != null}
<p class="dashboard-panel-note">{dashboard_skinFreshness({ count: daysSinceSnapshot })}</p>
{/if} {/if}
</section>
<div class="editorial-grid"> {#if latestSnapshot.active_concerns.length}
<section class="editorial-panel reveal-2"> <div class="concern-cloud" aria-label={skin_activeConcerns()}>
<header class="panel-header"> {#each latestSnapshot.active_concerns as concern (concern)}
<p class="panel-index">01</p> <span class="concern-chip">{formatConcern(concern)}</span>
<h3>{m["dashboard_latestSnapshot"]()}</h3>
</header>
<div class="panel-action-row">
<a href={resolve('/skin')} class="panel-action-link">{m["skin_addNew"]()}</a>
</div>
{#if data.latestSnapshot}
{@const s = data.latestSnapshot}
<div class="snapshot-meta-row">
<span class="snapshot-date">{s.snapshot_date}</span>
{#if s.overall_state}
<span class={stateTone[s.overall_state] ?? 'state-pill'}>{humanize(s.overall_state)}</span>
{/if}
</div>
{#if s.active_concerns.length}
<div class="concern-cloud" aria-label={m["skin_activeConcerns"]()}>
{#each s.active_concerns as concern (concern)}
<span class="concern-chip">{humanize(concern)}</span>
{/each} {/each}
</div> </div>
{/if} {/if}
{#if s.notes} {#if snapshotMetrics.length}
<p class="snapshot-notes">{s.notes}</p> <div class="dashboard-metric-row">
{#each snapshotMetrics as metric (metric.label)}
<div class="dashboard-metric-card">
<p class="dashboard-metric-label">{metric.label}</p>
<p class="dashboard-metric-value">{metric.value}</p>
{#if metric.trend}
<p class="dashboard-metric-trend">{dashboard_metricDelta({ delta: metric.trend })}</p>
{/if}
</div>
{/each}
</div>
{/if}
{#if latestSnapshot.notes}
<p class="snapshot-notes">{latestSnapshot.notes}</p>
{/if} {/if}
{:else} {:else}
<p class="empty-copy">{m["dashboard_noSnapshots"]()}</p> <p class="empty-copy">{dashboard_noSnapshots()}</p>
<div class="empty-actions">
<a href={resolve('/skin')} class="routine-summary-link">{skin_addNew()}</a>
</div>
{/if} {/if}
</section> </section>
<section class="editorial-panel reveal-3"> <section class="editorial-panel reveal-3">
<header class="panel-header"> <header class="panel-header">
<p class="panel-index">02</p> <p class="panel-index">03</p>
<h3>{m["dashboard_recentRoutines"]()}</h3> <h3>{dashboard_recentRoutines()}</h3>
</header> </header>
{#if data.recentRoutines.length} {#if sortedRoutines.length}
<div class="routine-summary-strip"> <div class="routine-summary-strip">
<span class="routine-summary-chip">AM {routineStats.amCount}</span> <span class="routine-summary-chip">AM {routineStats.amCount}</span>
<span class="routine-summary-chip">PM {routineStats.pmCount}</span> <span class="routine-summary-chip">PM {routineStats.pmCount}</span>
<a href={resolve('/routines/new')} class="routine-summary-link">{m["routines_addNew"]()}</a> <span class="routine-summary-chip">{dashboard_averageSteps({ count: routineStats.averageSteps })}</span>
<a href={resolve('/routines/new')} class="routine-summary-link">{routines_addNew()}</a>
</div> </div>
{#if latestRoutine}
<a href={resolve(`/routines/${latestRoutine.id}`)} class="dashboard-featured-routine">
<div class="dashboard-featured-routine-topline">
<p class="dashboard-featured-label">{dashboard_lastRoutine()}</p>
<span class={routineTone[latestRoutine.part_of_day] ?? 'routine-pill'}>
{latestRoutine.part_of_day.toUpperCase()}
</span>
</div>
<p class="dashboard-featured-value">{latestRoutine.routine_date}</p>
<p class="dashboard-featured-meta">{latestRoutine.steps?.length ?? 0} {common_steps()}</p>
{#if latestRoutine.notes}
<p class="dashboard-featured-notes">{latestRoutine.notes}</p>
{/if}
</a>
{/if}
<ol class="routine-list"> <ol class="routine-list">
{#each data.recentRoutines as routine (routine.id)} {#each sortedRoutines.slice(0, 5) as routine (routine.id)}
<li class="routine-item"> <li class="routine-item">
<a href={resolve(`/routines/${routine.id}`)} class="routine-link"> <a href={resolve(`/routines/${routine.id}`)} class="routine-link">
<div class="routine-main"> <div class="routine-main">
@ -116,9 +376,9 @@
</span> </span>
</div> </div>
<div class="routine-meta"> <div class="routine-meta">
<span>{routine.steps?.length ?? 0} {m.common_steps()}</span> <span>{routine.steps?.length ?? 0} {common_steps()}</span>
{#if routine.notes} {#if routine.notes}
<span class="routine-note-inline">{m.routines_notes()}: {routine.notes}</span> <span class="routine-note-inline">{routines_notes()}: {routine.notes}</span>
{/if} {/if}
</div> </div>
</div> </div>
@ -127,11 +387,49 @@
{/each} {/each}
</ol> </ol>
{:else} {:else}
<p class="empty-copy">{m["dashboard_noRoutines"]()}</p> <p class="empty-copy">{dashboard_noRoutines()}</p>
<div class="empty-actions"> <div class="empty-actions">
<a href={resolve('/routines/new')} class="routine-summary-link">{m["routines_addNew"]()}</a> <a href={resolve('/routines/new')} class="routine-summary-link">{routines_addNew()}</a>
</div> </div>
{/if} {/if}
</section> </section>
<section class="editorial-panel reveal-3">
<header class="panel-header">
<p class="panel-index">04</p>
<h3>{dashboard_healthPulse()}</h3>
</header>
<div class="panel-action-row">
<a href={resolve('/health/lab-results')} class="panel-action-link">{dashboard_viewLabResults()}</a>
</div>
{#if sortedLabResults.length}
<div class="lab-results-meta-strip dashboard-health-meta-strip">
<span class="lab-results-meta-pill">{dashboard_lastLabDate()}: {latestLabDate}</span>
<span class="lab-results-meta-pill">{dashboard_flaggedLabsCount({ count: flaggedLabs.length })}</span>
</div>
<div class="dashboard-health-list">
{#each featuredLabs as lab (lab.record_id)}
<a href={resolve('/health/lab-results')} class="dashboard-health-item">
<div>
<p class="dashboard-health-test">{formatLabLabel(lab)}</p>
<p class="dashboard-health-subline">{lab.collected_at.slice(0, 10)} - {lab.test_code}</p>
</div>
<div class="dashboard-health-value-wrap">
{#if lab.flag}
<span class={flaggedLabs.some((item) => item.record_id === lab.record_id) ? 'health-flag-pill health-flag-pill--abnormal' : 'health-flag-pill health-flag-pill--normal'}>
{lab.flag}
</span>
{/if}
<span class="dashboard-health-value">{formatLabValue(lab)}</span>
</div>
</a>
{/each}
</div>
{:else}
<p class="empty-copy">{dashboard_noLabResults()}</p>
{/if}
</section>
</div> </div>
</div> </div>

View file

@ -1,4 +1,4 @@
import { createLabResult, deleteLabResult, getLabResults, updateLabResult } from '$lib/api'; import { 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';
@ -41,38 +41,6 @@ export const load: PageServerLoad = async ({ url }) => {
}; };
export const actions: Actions = { export const actions: Actions = {
create: async ({ request }) => {
const form = await request.formData();
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 value_num = form.get('value_num') as string;
const unit_original = form.get('unit_original') as string;
const flag = form.get('flag') as string;
const lab = form.get('lab') as string;
if (!collected_at || !test_code) {
return fail(400, { error: 'Date and test code are required' });
}
const body: Record<string, unknown> = {
collected_at,
test_code
};
if (test_name_original) body.test_name_original = test_name_original;
if (value_num) body.value_num = Number(value_num);
if (unit_original) body.unit_original = unit_original;
if (flag) body.flag = flag;
if (lab) body.lab = lab;
try {
await createLabResult(body);
return { created: true };
} catch (e) {
return fail(500, { error: (e as Error).message });
}
},
update: async ({ request }) => { update: async ({ request }) => {
const form = await request.formData(); const form = await request.formData();
const id = form.get('id') as string; const id = form.get('id') as string;

View file

@ -1,14 +1,16 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import FlashMessages from '$lib/components/FlashMessages.svelte';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import PageHeader from '$lib/components/PageHeader.svelte';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { baseSelectClass, baseTextareaClass } from '$lib/components/forms/form-classes'; import { baseSelectClass, baseTextareaClass } 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 { Pencil, X } from 'lucide-svelte'; import { Pencil, X } from 'lucide-svelte';
import { preventIfNotConfirmed } from '$lib/utils/forms';
import { import {
Table, Table,
TableBody, TableBody,
@ -30,8 +32,6 @@
H: 'health-flag-pill health-flag-pill--high' H: 'health-flag-pill health-flag-pill--high'
}; };
let showForm = $state(false);
let selectedFlag = $state('');
let editingId = $state<string | null>(null); let editingId = $state<string | null>(null);
let editCollectedAt = $state(''); let editCollectedAt = $state('');
let editTestCode = $state(''); let editTestCode = $state('');
@ -185,21 +185,27 @@
return '—'; return '—';
} }
const flagOptions = flags.map((f) => ({ value: f, label: f }));
const textareaClass = `${baseTextareaClass} min-h-[5rem] resize-y`; const textareaClass = `${baseTextareaClass} min-h-[5rem] resize-y`;
let filterFlagOverride = $state<string | null>(null); let filterFlagOverride = $state<string | null>(null);
let filterLatestOnlyOverride = $state<'true' | 'false' | null>(null); let filterLatestOnlyOverride = $state<'true' | 'false' | null>(null);
const activeFilterFlag = $derived(filterFlagOverride ?? (data.flag ?? '')); const activeFilterFlag = $derived(filterFlagOverride ?? (data.flag ?? ''));
const activeLatestOnly = $derived(filterLatestOnlyOverride ?? (data.latestOnly ? 'true' : 'false')); const activeLatestOnly = $derived(filterLatestOnlyOverride ?? (data.latestOnly ? 'true' : 'false'));
const flashMessages = $derived([
...(form?.error ? [{ kind: 'error' as const, text: form.error }] : []),
...(form?.deleted ? [{ kind: 'success' as const, text: m["labResults_deleted"]() }] : []),
...(form?.updated ? [{ kind: 'success' as const, text: m["labResults_updated"]() }] : [])
]);
</script> </script>
<svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head> <svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head>
<div class="editorial-page space-y-4"> <div class="editorial-page space-y-4">
<section class="editorial-hero reveal-1"> <PageHeader
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> title={m["labResults_title"]()}
<h2 class="editorial-title">{m["labResults_title"]()}</h2> kicker={m["nav_appSubtitle"]()}
<p class="editorial-subtitle">{m["labResults_count"]({ count: data.resultPage.total })}</p> subtitle={m["labResults_count"]({ count: data.resultPage.total })}
>
{#snippet meta()}
<div class="lab-results-meta-strip"> <div class="lab-results-meta-strip">
<span class="lab-results-meta-pill"> <span class="lab-results-meta-pill">
{m['labResults_view']()}: {data.latestOnly ? m['labResults_viewLatest']() : m['labResults_viewAll']()} {m['labResults_view']()}: {data.latestOnly ? m['labResults_viewLatest']() : m['labResults_viewAll']()}
@ -214,25 +220,14 @@
<span class="lab-results-meta-pill lab-results-meta-pill--alert">{data.test_code}</span> <span class="lab-results-meta-pill lab-results-meta-pill--alert">{data.test_code}</span>
{/if} {/if}
</div> </div>
<div class="editorial-toolbar"> {/snippet}
<Button variant="outline" onclick={() => (showForm = !showForm)}>
{showForm ? m.common_cancel() : m["labResults_addNew"]()}
</Button>
</div>
</section>
{#if form?.error} {#snippet actions()}
<div class="editorial-alert editorial-alert--error">{form.error}</div> <Button variant="outline" href="/health/lab-results/new">{m["labResults_addNew"]()}</Button>
{/if} {/snippet}
{#if form?.created} </PageHeader>
<div class="editorial-alert editorial-alert--success">{m["labResults_added"]()}</div>
{/if} <FlashMessages messages={flashMessages} />
{#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}
{#if editingId} {#if editingId}
<FormSectionCard title={m["labResults_editTitle"]()} className="reveal-2"> <FormSectionCard title={m["labResults_editTitle"]()} className="reveal-2">
@ -457,56 +452,6 @@
</div> </div>
{/if} {/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} {#if data.totalPages > 1}
<div class="editorial-panel lab-results-pager reveal-2 flex items-center justify-between gap-3"> <div class="editorial-panel lab-results-pager reveal-2 flex items-center justify-between gap-3">
<Button <Button
@ -545,9 +490,7 @@
method="POST" method="POST"
action="?/delete" action="?/delete"
use:enhance use:enhance
onsubmit={(event) => { onsubmit={(event) => preventIfNotConfirmed(event, m['labResults_confirmDelete']())}
if (!confirm(m['labResults_confirmDelete']())) event.preventDefault();
}}
> >
<input type="hidden" name="id" value={r.record_id} /> <input type="hidden" name="id" value={r.record_id} />
<Button <Button
@ -573,9 +516,7 @@
method="POST" method="POST"
action="?/delete" action="?/delete"
use:enhance use:enhance
onsubmit={(event) => { onsubmit={(event) => preventIfNotConfirmed(event, m['labResults_confirmDelete']())}
if (!confirm(m['labResults_confirmDelete']())) event.preventDefault();
}}
> >
<input type="hidden" name="id" value={r.record_id} /> <input type="hidden" name="id" value={r.record_id} />
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive"> <Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">
@ -615,7 +556,7 @@
{/snippet} {/snippet}
{#snippet mobileCard(r: LabResultItem)} {#snippet mobileCard(r: LabResultItem)}
<div class="products-mobile-card lab-results-mobile-card flex flex-col gap-1"> <div class="editorial-mobile-card lab-results-mobile-card flex flex-col gap-1">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="flex min-w-0 flex-col gap-1"> <div class="flex min-w-0 flex-col gap-1">
<button <button
@ -649,7 +590,7 @@
{/snippet} {/snippet}
<!-- Desktop: table --> <!-- Desktop: table -->
<div class="products-table-shell lab-results-table hidden md:block reveal-2"> <div class="editorial-table-shell lab-results-table hidden md:block reveal-2">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -667,7 +608,7 @@
{#if group.label} {#if group.label}
<TableRow> <TableRow>
<TableCell colspan={7} class="bg-muted/35 py-2"> <TableCell colspan={7} class="bg-muted/35 py-2">
<div class="products-section-title text-xs uppercase tracking-[0.12em]"> <div class="editorial-section-title text-xs uppercase tracking-[0.12em]">
{group.label} {group.label}
</div> </div>
</TableCell> </TableCell>
@ -692,7 +633,7 @@
<div class="lab-results-mobile-grid 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 displayGroups as group (group.key)} {#each displayGroups as group (group.key)}
{#if group.label} {#if group.label}
<div class="products-section-title text-xs uppercase tracking-[0.12em]">{group.label}</div> <div class="editorial-section-title text-xs uppercase tracking-[0.12em]">{group.label}</div>
{/if} {/if}
{#each group.items as r (r.record_id)} {#each group.items as r (r.record_id)}
{@render mobileCard(r)} {@render mobileCard(r)}

View file

@ -0,0 +1,42 @@
import { createLabResult } from '$lib/api';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return {};
};
export const actions: Actions = {
default: async ({ request }) => {
const form = await request.formData();
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 value_num = form.get('value_num') as string;
const unit_original = form.get('unit_original') as string;
const flag = form.get('flag') as string;
const lab = form.get('lab') as string;
if (!collected_at || !test_code) {
return fail(400, { error: 'Date and test code are required' });
}
const body: Record<string, unknown> = {
collected_at,
test_code
};
if (test_name_original) body.test_name_original = test_name_original;
if (value_num) body.value_num = Number(value_num);
if (unit_original) body.unit_original = unit_original;
if (flag) body.flag = flag;
if (lab) body.lab = lab;
try {
await createLabResult(body);
} catch (error) {
return fail(500, { error: (error as Error).message });
}
redirect(303, '/health/lab-results');
}
};

View file

@ -0,0 +1,68 @@
<script lang="ts">
import { resolve } from '$app/paths';
import FlashMessages from '$lib/components/FlashMessages.svelte';
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as m from '$lib/paraglide/messages.js';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let selectedFlag = $state('');
const flags = ['N', 'ABN', 'POS', 'NEG', 'L', 'H'];
const flagOptions = flags.map((flag) => ({ value: flag, label: flag }));
const flashMessages = $derived(form?.error ? [{ kind: 'error' as const, text: form.error }] : []);
</script>
<svelte:head><title>{m['labResults_newTitle']()} — innercontext</title></svelte:head>
<div class="editorial-page space-y-4">
<PageHeader
title={m['labResults_newTitle']()}
kicker={m['nav_appSubtitle']()}
backHref={resolve('/health/lab-results')}
backLabel={m['labResults_title']()}
subtitle={m['labResults_newSubtitle']()}
/>
<FlashMessages messages={flashMessages} />
<FormSectionCard title={m['labResults_newTitle']()} className="reveal-2">
<form method="POST" class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<p class="sm:col-span-2 text-sm leading-6 text-muted-foreground">{m['labResults_newSectionIntro']()}</p>
<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">{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-center justify-end gap-2 sm:col-span-2">
<Button type="button" variant="outline" href={resolve('/health/lab-results')}>{m.common_cancel()}</Button>
<Button type="submit">{m.common_add()}</Button>
</div>
</form>
</FormSectionCard>
</div>

View file

@ -1,35 +1,8 @@
import { createMedication, getMedications } from '$lib/api'; import { getMedications } from '$lib/api';
import { fail } from '@sveltejs/kit'; import type { PageServerLoad } from './$types';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => { export const load: PageServerLoad = async ({ url }) => {
const kind = url.searchParams.get('kind') ?? undefined; const kind = url.searchParams.get('kind') ?? undefined;
const medications = await getMedications({ kind }); const medications = await getMedications({ kind });
return { medications, kind }; return { medications, kind };
}; };
export const actions: Actions = {
create: async ({ request }) => {
const form = await request.formData();
const kind = form.get('kind') as string;
const product_name = form.get('product_name') as string;
const active_substance = form.get('active_substance') as string;
const notes = form.get('notes') as string;
if (!kind || !product_name) {
return fail(400, { error: 'Kind and product name are required' });
}
try {
await createMedication({
kind,
product_name,
active_substance: active_substance || undefined,
notes: notes || undefined
});
return { created: true };
} catch (e) {
return fail(500, { error: (e as Error).message });
}
}
};

View file

@ -1,19 +1,10 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import type { PageData } from './$types';
import type { ActionData, PageData } from './$types'; import * as 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 { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data }: { data: PageData } = $props();
const kinds = ['prescription', 'otc', 'supplement', 'herbal', 'other'];
let showForm = $state(false);
let kind = $state('supplement');
const kindPills: Record<string, string> = { const kindPills: Record<string, string> = {
prescription: 'health-kind-pill health-kind-pill--prescription', prescription: 'health-kind-pill health-kind-pill--prescription',
@ -31,7 +22,6 @@
other: m["medications_kindOther"] other: m["medications_kindOther"]
}; };
const kindOptions = $derived(kinds.map((k) => ({ value: k, label: kindLabels[k]?.() ?? k })));
</script> </script>
<svelte:head><title>{m.medications_title()} — innercontext</title></svelte:head> <svelte:head><title>{m.medications_title()} — innercontext</title></svelte:head>
@ -39,53 +29,13 @@
<div class="editorial-page space-y-4"> <div class="editorial-page space-y-4">
<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.medications_title()}</h2> <h1 class="editorial-title">{m.medications_title()}</h1>
<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 variant="outline" onclick={() => (showForm = !showForm)}> <Button variant="outline" href="/health/medications/new">{m["medications_addNew"]()}</Button>
{showForm ? m.common_cancel() : m["medications_addNew"]()}
</Button>
</div> </div>
</section> </section>
{#if form?.error}
<div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if}
{#if form?.created}
<div class="editorial-alert editorial-alert--success">{m.medications_added()}</div>
{/if}
{#if showForm}
<FormSectionCard title={m["medications_newTitle"]()} className="reveal-2">
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="col-span-2">
<SimpleSelect
id="kind"
name="kind"
label={m.medications_kind()}
options={kindOptions}
bind:value={kind}
/>
</div>
<div class="space-y-1">
<Label for="product_name">{m["medications_productName"]()}</Label>
<Input id="product_name" name="product_name" required placeholder={m["medications_productNamePlaceholder"]()} />
</div>
<div class="space-y-1">
<Label for="active_substance">{m["medications_activeSubstance"]()}</Label>
<Input id="active_substance" name="active_substance" placeholder={m["medications_activeSubstancePlaceholder"]()} />
</div>
<div class="space-y-1 col-span-2">
<Label for="notes">{m.medications_notes()}</Label>
<Input id="notes" name="notes" />
</div>
<div class="col-span-2">
<Button type="submit">{m.common_add()}</Button>
</div>
</form>
</FormSectionCard>
{/if}
<div class="editorial-panel reveal-2 space-y-3"> <div class="editorial-panel reveal-2 space-y-3">
{#each data.medications as med (med.record_id)} {#each data.medications as med (med.record_id)}
<div class="health-entry-row"> <div class="health-entry-row">

View file

@ -0,0 +1,34 @@
import { createMedication } from '$lib/api';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return {};
};
export const actions: Actions = {
default: async ({ request }) => {
const form = await request.formData();
const kind = form.get('kind') as string;
const product_name = form.get('product_name') as string;
const active_substance = form.get('active_substance') as string;
const notes = form.get('notes') as string;
if (!kind || !product_name) {
return fail(400, { error: 'Kind and product name are required' });
}
try {
await createMedication({
kind,
product_name,
active_substance: active_substance || undefined,
notes: notes || undefined
});
} catch (error) {
return fail(500, { error: (error as Error).message });
}
redirect(303, '/health/medications');
}
};

View file

@ -0,0 +1,65 @@
<script lang="ts">
import { resolve } from '$app/paths';
import FlashMessages from '$lib/components/FlashMessages.svelte';
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as m from '$lib/paraglide/messages.js';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let kind = $state('supplement');
const kinds = ['prescription', 'otc', 'supplement', 'herbal', 'other'];
const kindLabels: Record<string, () => string> = {
prescription: m['medications_kindPrescription'],
otc: m['medications_kindOtc'],
supplement: m['medications_kindSupplement'],
herbal: m['medications_kindHerbal'],
other: m['medications_kindOther']
};
const kindOptions = kinds.map((entry) => ({ value: entry, label: kindLabels[entry]?.() ?? entry }));
const flashMessages = $derived(form?.error ? [{ kind: 'error' as const, text: form.error }] : []);
</script>
<svelte:head><title>{m['medications_newTitle']()} — innercontext</title></svelte:head>
<div class="editorial-page space-y-4">
<PageHeader
title={m['medications_newTitle']()}
kicker={m['nav_appSubtitle']()}
backHref={resolve('/health/medications')}
backLabel={m.medications_title()}
subtitle={m['medications_newSubtitle']()}
/>
<FlashMessages messages={flashMessages} />
<FormSectionCard title={m['medications_newTitle']()} className="reveal-2">
<form method="POST" class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<p class="sm:col-span-2 text-sm leading-6 text-muted-foreground">{m['medications_newSectionIntro']()}</p>
<div class="sm:col-span-2">
<SimpleSelect id="kind" name="kind" label={m.medications_kind()} options={kindOptions} bind:value={kind} />
</div>
<div class="space-y-1">
<Label for="product_name">{m['medications_productName']()}</Label>
<Input id="product_name" name="product_name" required placeholder={m['medications_productNamePlaceholder']()} />
</div>
<div class="space-y-1">
<Label for="active_substance">{m['medications_activeSubstance']()}</Label>
<Input id="active_substance" name="active_substance" placeholder={m['medications_activeSubstancePlaceholder']()} />
</div>
<div class="space-y-1 sm:col-span-2">
<Label for="notes">{m.medications_notes()}</Label>
<Input id="notes" name="notes" />
</div>
<div class="flex items-center justify-end gap-2 sm:col-span-2">
<Button type="button" variant="outline" href={resolve('/health/medications')}>{m.common_cancel()}</Button>
<Button type="submit">{m.common_add()}</Button>
</div>
</form>
</FormSectionCard>
</div>

View file

@ -2,6 +2,13 @@ import { getProductSummaries } from '$lib/api';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
try {
const products = await getProductSummaries(); const products = await getProductSummaries();
return { products }; return { products, loadError: null };
} catch (error) {
return {
products: [],
loadError: error instanceof Error ? error.message : 'Failed to load products'
};
}
}; };

View file

@ -3,7 +3,8 @@
import type { ProductSummary } from '$lib/types'; import type { ProductSummary } from '$lib/types';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import PageHeader from '$lib/components/PageHeader.svelte';
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 { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
@ -17,7 +18,7 @@
TableRow TableRow
} from '$lib/components/ui/table'; } from '$lib/components/ui/table';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData & { loadError: string | null } } = $props();
type OwnershipFilter = 'all' | 'owned' | 'unowned'; type OwnershipFilter = 'all' | 'owned' | 'unowned';
type SortKey = 'brand' | 'name' | 'time' | 'price'; type SortKey = 'brand' | 'name' | 'time' | 'price';
@ -106,6 +107,7 @@
})()); })());
const totalCount = $derived(groupedProducts.reduce((s, [, arr]) => s + arr.length, 0)); const totalCount = $derived(groupedProducts.reduce((s, [, arr]) => s + arr.length, 0));
const ownedCount = $derived(data.products.filter((product) => product.is_owned).length);
function formatPricePerUse(value?: number): string { function formatPricePerUse(value?: number): string {
if (value == null) return '-'; if (value == null) return '-';
@ -134,15 +136,35 @@
<svelte:head><title>{m.products_title()} — innercontext</title></svelte:head> <svelte:head><title>{m.products_title()} — innercontext</title></svelte:head>
<div class="editorial-page space-y-4"> <div class="editorial-page space-y-4">
<section class="editorial-hero reveal-1"> <PageHeader
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> title={m.products_title()}
<h2 class="editorial-title">{m.products_title()}</h2> kicker={m["nav_appSubtitle"]()}
<p class="editorial-subtitle">{m.products_count({ count: totalCount })}</p> subtitle={m.products_count({ count: totalCount })}
<div class="editorial-toolbar"> >
{#snippet actions()}
<Button href={resolve('/products/suggest')} variant="outline"><Sparkles class="size-4" /> {m["products_suggest"]()}</Button> <Button href={resolve('/products/suggest')} variant="outline"><Sparkles class="size-4" /> {m["products_suggest"]()}</Button>
<Button href={resolve('/products/new')}>{m["products_addNew"]()}</Button> <Button href={resolve('/products/new')}>{m["products_addNew"]()}</Button>
{/snippet}
<div class="hero-strip" aria-label={m.products_title()}>
<div>
<p class="hero-strip-label">{m["products_filterOwned"]()}</p>
<p class="hero-strip-value">{ownedCount}</p>
</div> </div>
</section> <div>
<p class="hero-strip-label">{m["products_filterAll"]()}</p>
<p class="hero-strip-value">{data.products.length}</p>
</div>
<div>
<p class="hero-strip-label">{m.products_count({ count: totalCount })}</p>
<p class="hero-strip-value">{totalCount}</p>
</div>
</div>
</PageHeader>
{#if data.loadError}
<div class="editorial-alert editorial-alert--warning">{data.loadError}</div>
{/if}
<div class="editorial-panel reveal-2"> <div class="editorial-panel reveal-2">
<div class="editorial-filter-row"> <div class="editorial-filter-row">
@ -187,7 +209,7 @@
</div> </div>
<!-- Desktop: table --> <!-- Desktop: table -->
<div class="products-table-shell hidden md:block reveal-2"> <div class="editorial-table-shell hidden md:block reveal-2">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -251,13 +273,13 @@
<p class="py-8 text-center text-sm text-muted-foreground">{m["products_noProducts"]()}</p> <p class="py-8 text-center text-sm text-muted-foreground">{m["products_noProducts"]()}</p>
{:else} {:else}
{#each groupedProducts as [category, products] (category)} {#each groupedProducts as [category, products] (category)}
<div class="products-section-title"> <div class="editorial-section-title">
{formatCategory(category)} {formatCategory(category)}
</div> </div>
{#each products as product (product.id)} {#each products as product (product.id)}
<a <a
href={resolve(`/products/${product.id}`)} href={resolve(`/products/${product.id}`)}
class={`products-mobile-card ${isOwned(product) ? 'hover:bg-muted/50' : 'bg-muted/20 text-muted-foreground hover:bg-muted/30'}`} class={`editorial-mobile-card ${isOwned(product) ? 'hover:bg-muted/50' : 'bg-muted/20 text-muted-foreground hover:bg-muted/30'}`}
> >
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="min-w-0"> <div class="min-w-0">

View file

@ -3,15 +3,19 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte'; import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import FlashMessages from '$lib/components/FlashMessages.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
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 { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
import { Save, Trash2, Boxes, Pencil, X, ArrowLeft, Sparkles } from 'lucide-svelte'; import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
import { Save, Trash2, Boxes, Pencil, X, Sparkles } from 'lucide-svelte';
import ProductForm from '$lib/components/ProductForm.svelte'; import ProductForm from '$lib/components/ProductForm.svelte';
import { preventIfNotConfirmed } from '$lib/utils/forms';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
let { product } = $derived(data); let { product } = $derived(data);
@ -77,41 +81,31 @@
if (value === 'nearly_empty') return m['inventory_remainingNearlyEmpty'](); if (value === 'nearly_empty') return m['inventory_remainingNearlyEmpty']();
return ''; return '';
} }
const canSaveEdit = $derived(activeTab === 'edit' && isEditDirty);
const pageFlashMessages = $derived([
...(form?.error ? [{ kind: 'error' as const, text: form.error }] : []),
...(form?.success ? [{ kind: 'success' as const, text: m.common_saved() }] : [])
]);
const inventoryFlashMessages = $derived([
...(form?.inventoryAdded ? [{ kind: 'success' as const, text: m["inventory_packageAdded"]() }] : []),
...(form?.inventoryUpdated ? [{ kind: 'success' as const, text: m["inventory_packageUpdated"]() }] : []),
...(form?.inventoryDeleted ? [{ kind: 'success' as const, text: m["inventory_packageDeleted"]() }] : [])
]);
</script> </script>
<svelte:head><title>{product.name} — innercontext</title></svelte:head> <svelte:head><title>{product.name} — innercontext</title></svelte:head>
<div class="products-sticky-actions fixed inset-x-3 bottom-3 z-40 flex items-center justify-end gap-2 rounded-lg border border-border bg-card/95 p-2 shadow-sm backdrop-blur md:inset-x-auto md:bottom-auto md:right-6 md:top-4"> <div class="editorial-page space-y-4">
<Button <PageHeader
type="submit" title={product.name}
form="product-edit-form" kicker={m["nav_appSubtitle"]()}
disabled={activeTab !== 'edit' || !isEditDirty} backHref={resolve('/products')}
size="sm" backLabel={m["products_backToList"]()}
titleClassName="break-words editorial-title"
> >
<Save class="size-4" aria-hidden="true" /> {#snippet meta()}
<span class="sr-only md:not-sr-only">{m["products_saveChanges"]()}</span> <div class="editorial-meta-strip">
</Button>
<form
method="POST"
action="?/delete"
use:enhance
onsubmit={(e) => {
if (!confirm(m["products_confirmDelete"]())) e.preventDefault();
}}
>
<Button type="submit" variant="destructive" size="sm">
<Trash2 class="size-4" aria-hidden="true" />
<span class="sr-only md:not-sr-only">{m["products_deleteProduct"]()}</span>
</Button>
</form>
</div>
<div class="editorial-page space-y-4 pb-20 md:pb-0">
<section class="editorial-hero reveal-1 space-y-3">
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
<h2 class="break-words editorial-title">{product.name}</h2>
<div class="products-meta-strip">
<span class="font-medium text-foreground">{product.brand}</span> <span class="font-medium text-foreground">{product.brand}</span>
<span></span> <span></span>
<span class="uppercase">{product.recommended_time}</span> <span class="uppercase">{product.recommended_time}</span>
@ -119,14 +113,34 @@
<span>{product.category.replace(/_/g, ' ')}</span> <span>{product.category.replace(/_/g, ' ')}</span>
<Badge variant="outline" class="ml-1">ID: {product.id.slice(0, 8)}</Badge> <Badge variant="outline" class="ml-1">ID: {product.id.slice(0, 8)}</Badge>
</div> </div>
</section> {/snippet}
{#if form?.error} {#snippet actions()}
<div class="editorial-alert editorial-alert--error">{form.error}</div> {#if activeTab === 'edit'}
{/if} <Button type="submit" form="product-edit-form" disabled={!canSaveEdit} size="sm">
{#if form?.success} <Save class="size-4" aria-hidden="true" />
<div class="editorial-alert editorial-alert--success">{m.common_saved()}</div> {m["products_saveChanges"]()}
</Button>
{/if} {/if}
{/snippet}
<div class="hero-strip" aria-label={product.name}>
<div>
<p class="hero-strip-label">{m.inventory_title({ count: product.inventory.length })}</p>
<p class="hero-strip-value">{product.inventory.length}</p>
</div>
<div>
<p class="hero-strip-label">{m["productForm_price"]()}</p>
<p class="hero-strip-value">{formatAmount(getPriceAmount(), getPriceCurrency())}</p>
</div>
<div>
<p class="hero-strip-label">{m["products_colPricePerUse"]()}</p>
<p class="hero-strip-value">{formatPricePerUse(getPricePerUse())}</p>
</div>
</div>
</PageHeader>
<FlashMessages messages={pageFlashMessages} />
<Tabs bind:value={activeTab} class="products-tabs space-y-2 reveal-2"> <Tabs bind:value={activeTab} class="products-tabs space-y-2 reveal-2">
<TabsList class="h-auto w-full justify-start gap-1 overflow-x-auto p-1 whitespace-nowrap"> <TabsList class="h-auto w-full justify-start gap-1 overflow-x-auto p-1 whitespace-nowrap">
@ -149,15 +163,7 @@
</Button> </Button>
</div> </div>
{#if form?.inventoryAdded} <FlashMessages messages={inventoryFlashMessages} />
<div class="editorial-alert editorial-alert--success">{m["inventory_packageAdded"]()}</div>
{/if}
{#if form?.inventoryUpdated}
<div class="editorial-alert editorial-alert--success">{m["inventory_packageUpdated"]()}</div>
{/if}
{#if form?.inventoryDeleted}
<div class="editorial-alert editorial-alert--success">{m["inventory_packageDeleted"]()}</div>
{/if}
{#if showInventoryForm} {#if showInventoryForm}
<Card> <Card>
@ -196,7 +202,8 @@
<Label for="add_notes">{m.inventory_notes()}</Label> <Label for="add_notes">{m.inventory_notes()}</Label>
<Input id="add_notes" name="notes" /> <Input id="add_notes" name="notes" />
</div> </div>
<div class="flex items-end"> <div class="flex items-end gap-2 sm:col-span-2 sm:justify-end">
<Button type="button" variant="outline" size="sm" onclick={() => (showInventoryForm = false)}>{m.common_cancel()}</Button>
<Button type="submit" size="sm">{m.common_add()}</Button> <Button type="submit" size="sm">{m.common_add()}</Button>
</div> </div>
</form> </form>
@ -251,7 +258,7 @@
method="POST" method="POST"
action="?/deleteInventory" action="?/deleteInventory"
use:enhance use:enhance
onsubmit={(e) => { if (!confirm(m["inventory_confirmDelete"]())) e.preventDefault(); }} onsubmit={(e) => preventIfNotConfirmed(e, m["inventory_confirmDelete"]())}
> >
<input type="hidden" name="inventory_id" value={pkg.id} /> <input type="hidden" name="inventory_id" value={pkg.id} />
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive"><X class="size-4" /></Button> <Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive"><X class="size-4" /></Button>
@ -325,7 +332,9 @@
{/each} {/each}
</div> </div>
{:else} {:else}
<div class="editorial-panel">
<p class="text-sm text-muted-foreground">{m["products_noInventory"]()}</p> <p class="text-sm text-muted-foreground">{m["products_noInventory"]()}</p>
</div>
{/if} {/if}
</div> </div>
</TabsContent> </TabsContent>
@ -370,6 +379,21 @@
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
<FormSectionCard title={m["products_deleteProduct"]()} contentClassName="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm text-muted-foreground">{m["products_confirmDelete"]()}</p>
<form
method="POST"
action="?/delete"
use:enhance
onsubmit={(e) => preventIfNotConfirmed(e, m["products_confirmDelete"]())}
>
<Button type="submit" variant="destructive" size="sm">
<Trash2 class="size-4" aria-hidden="true" />
{m["products_deleteProduct"]()}
</Button>
</form>
</FormSectionCard>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>

View file

@ -2,9 +2,9 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import type { ActionData } from './$types'; import type { ActionData } from './$types';
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import PageHeader from '$lib/components/PageHeader.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { ArrowLeft } from 'lucide-svelte';
import ProductForm from '$lib/components/ProductForm.svelte'; import ProductForm from '$lib/components/ProductForm.svelte';
let { form }: { form: ActionData } = $props(); let { form }: { form: ActionData } = $props();
@ -21,11 +21,12 @@
<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-hero reveal-1 space-y-3"> <PageHeader
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a> title={m["products_newTitle"]()}
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> kicker={m["nav_appSubtitle"]()}
<h2 class="editorial-title">{m["products_newTitle"]()}</h2> backHref={resolve('/products')}
</section> backLabel={m["products_backToList"]()}
/>
{#if form?.error} {#if form?.error}
<div bind:this={errorEl} class="editorial-alert editorial-alert--error"> <div bind:this={errorEl} class="editorial-alert editorial-alert--error">

View file

@ -2,11 +2,12 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import type { ProductSuggestion, ResponseMetadata, ShoppingPriority } from '$lib/types'; import type { ProductSuggestion, ResponseMetadata, ShoppingPriority } from '$lib/types';
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import PageHeader from '$lib/components/PageHeader.svelte';
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 { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Sparkles, ArrowLeft } from 'lucide-svelte'; import { Sparkles } from 'lucide-svelte';
import ValidationWarningsAlert from '$lib/components/ValidationWarningsAlert.svelte'; import ValidationWarningsAlert from '$lib/components/ValidationWarningsAlert.svelte';
import StructuredErrorDisplay from '$lib/components/StructuredErrorDisplay.svelte'; import StructuredErrorDisplay from '$lib/components/StructuredErrorDisplay.svelte';
import AutoFixBadge from '$lib/components/AutoFixBadge.svelte'; import AutoFixBadge from '$lib/components/AutoFixBadge.svelte';
@ -68,12 +69,13 @@
<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-hero reveal-1 space-y-3"> <PageHeader
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a> title={m["products_suggestTitle"]()}
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> kicker={m["nav_appSubtitle"]()}
<h2 class="editorial-title">{m["products_suggestTitle"]()}</h2> subtitle={m["products_suggestSubtitle"]()}
<p class="editorial-subtitle">{m["products_suggestSubtitle"]()}</p> backHref={resolve('/products')}
</section> backLabel={m["products_backToList"]()}
/>
{#if errorMsg} {#if errorMsg}
<StructuredErrorDisplay error={errorMsg} /> <StructuredErrorDisplay error={errorMsg} />

View file

@ -2,7 +2,9 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import FlashMessages from '$lib/components/FlashMessages.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte'; import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
import LabeledInputField from '$lib/components/forms/LabeledInputField.svelte'; import LabeledInputField from '$lib/components/forms/LabeledInputField.svelte';
@ -18,23 +20,23 @@
{ value: 'male', label: m.profile_sexMale() }, { value: 'male', label: m.profile_sexMale() },
{ value: 'intersex', label: m.profile_sexIntersex() } { value: 'intersex', label: m.profile_sexIntersex() }
]); ]);
const flashMessages = $derived([
...(form?.error ? [{ kind: 'error' as const, text: form.error }] : []),
...(form?.saved ? [{ kind: 'success' as const, text: m.profile_saved() }] : [])
]);
</script> </script>
<svelte:head><title>{m.profile_title()} — innercontext</title></svelte:head> <svelte:head><title>{m.profile_title()} — innercontext</title></svelte:head>
<div class="editorial-page space-y-4"> <div class="editorial-page space-y-4">
<section class="editorial-hero reveal-1"> <PageHeader
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> title={m.profile_title()}
<h2 class="editorial-title">{m.profile_title()}</h2> kicker={m["nav_appSubtitle"]()}
<p class="editorial-subtitle">{m.profile_subtitle()}</p> subtitle={m.profile_subtitle()}
</section> />
{#if form?.error} <FlashMessages messages={flashMessages} />
<div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if}
{#if form?.saved}
<div class="editorial-alert editorial-alert--success">{m.profile_saved()}</div>
{/if}
<form method="POST" action="?/save" use:enhance class="reveal-2 space-y-4"> <form method="POST" action="?/save" use:enhance class="reveal-2 space-y-4">
<FormSectionCard title={m.profile_sectionBasic()} contentClassName="space-y-4"> <FormSectionCard title={m.profile_sectionBasic()} contentClassName="space-y-4">

View file

@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import PageHeader from '$lib/components/PageHeader.svelte';
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'; import { Sparkles } from 'lucide-svelte';
@ -24,21 +25,22 @@
<svelte:head><title>{m.routines_title()} — innercontext</title></svelte:head> <svelte:head><title>{m.routines_title()} — innercontext</title></svelte:head>
<div class="editorial-page space-y-4"> <div class="editorial-page space-y-4">
<section class="editorial-hero reveal-1"> <PageHeader
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> title={m.routines_title()}
<h2 class="editorial-title">{m.routines_title()}</h2> kicker={m["nav_appSubtitle"]()}
<p class="editorial-subtitle">{m.routines_count({ count: data.routines.length })}</p> subtitle={m.routines_count({ count: data.routines.length })}
<div class="editorial-toolbar"> >
{#snippet actions()}
<Button href={resolve('/routines/suggest')} variant="outline"><Sparkles class="size-4" /> {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> {/snippet}
</section> </PageHeader>
{#if sortedDates.length} {#if sortedDates.length}
<div class="editorial-panel reveal-2 space-y-4"> <div class="editorial-panel reveal-2 space-y-4">
{#each sortedDates as date (date)} {#each sortedDates as date (date)}
<div> <div>
<h3 class="products-section-title mb-2">{date}</h3> <h3 class="editorial-section-title mb-2">{date}</h3>
<div class="space-y-1"> <div class="space-y-1">
{#each byDate[date] as routine (routine.id)} {#each byDate[date] as routine (routine.id)}
<a <a

View file

@ -5,17 +5,19 @@
import { updateRoutineStep } from '$lib/api'; import { updateRoutineStep } from '$lib/api';
import type { GroomingAction, RoutineStep } from '$lib/types'; import type { GroomingAction, RoutineStep } from '$lib/types';
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import FlashMessages from '$lib/components/FlashMessages.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
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 { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import GroupedSelect from '$lib/components/forms/GroupedSelect.svelte'; import GroupedSelect from '$lib/components/forms/GroupedSelect.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte'; import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { Separator } from '$lib/components/ui/separator'; import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
import { GripVertical, Pencil, X, ArrowLeft } from 'lucide-svelte'; import { GripVertical, Pencil, X } from 'lucide-svelte';
import { preventIfNotConfirmed } from '$lib/utils/forms';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
let { routine, products } = $derived(data); let { routine, products } = $derived(data);
@ -144,28 +146,53 @@
value: action, value: action,
label: action.replace(/_/g, ' ') label: action.replace(/_/g, ' ')
})); }));
const flashMessages = $derived([
...(form?.error ? [{ kind: 'error' as const, text: form.error }] : [])
]);
</script> </script>
<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-hero reveal-1 space-y-3"> <PageHeader
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a> title={routine.routine_date}
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> kicker={m["nav_appSubtitle"]()}
<div class="flex items-center gap-3"> backHref={resolve('/routines')}
<h2 class="editorial-title text-[clamp(1.8rem,3vw,2.4rem)]">{routine.routine_date}</h2> backLabel={m["routines_backToList"]()}
titleClassName="editorial-title text-[clamp(1.8rem,3vw,2.4rem)]"
>
{#snippet meta()}
<div class="editorial-meta-strip">
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}> <Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
{routine.part_of_day.toUpperCase()} {routine.part_of_day.toUpperCase()}
</Badge> </Badge>
</div> <span>{m.routines_steps({ count: steps.length })}</span>
</section> {#if routine.notes}
<span></span>
{#if form?.error} <span class="max-w-[46ch] truncate">{routine.notes}</span>
<div class="editorial-alert editorial-alert--error">{form.error}</div>
{/if} {/if}
</div>
{/snippet}
<div class="hero-strip" aria-label={routine.routine_date}>
<div>
<p class="hero-strip-label">{m["routines_amOrPm"]()}</p>
<p class="hero-strip-value">{routine.part_of_day.toUpperCase()}</p>
</div>
<div>
<p class="hero-strip-label">{m.routines_steps({ count: steps.length })}</p>
<p class="hero-strip-value">{steps.length}</p>
</div>
</div>
</PageHeader>
<FlashMessages messages={flashMessages} />
{#if routine.notes} {#if routine.notes}
<p class="text-sm text-muted-foreground">{routine.notes}</p> <div class="editorial-panel reveal-2">
<p class="text-sm leading-6 text-muted-foreground">{routine.notes}</p>
</div>
{/if} {/if}
<!-- Steps --> <!-- Steps -->
@ -178,9 +205,7 @@
</div> </div>
{#if showStepForm} {#if showStepForm}
<Card> <FormSectionCard title={m["routines_addStepTitle"]()}>
<CardHeader><CardTitle class="text-base">{m["routines_addStepTitle"]()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/addStep" use:enhance class="space-y-4"> <form method="POST" action="?/addStep" use:enhance class="space-y-4">
<GroupedSelect <GroupedSelect
id="new_step_product" id="new_step_product"
@ -191,7 +216,7 @@
bind:value={selectedProductId} bind:value={selectedProductId}
/> />
<input type="hidden" name="order_index" value={nextOrderIndex} /> <input type="hidden" name="order_index" value={nextOrderIndex} />
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="space-y-1"> <div class="space-y-1">
<Label for="dose">{m.routines_dose()}</Label> <Label for="dose">{m.routines_dose()}</Label>
<Input id="dose" name="dose" placeholder={m["routines_dosePlaceholder"]()} /> <Input id="dose" name="dose" placeholder={m["routines_dosePlaceholder"]()} />
@ -201,10 +226,12 @@
<Input id="region" name="region" placeholder={m["routines_regionPlaceholder"]()} /> <Input id="region" name="region" placeholder={m["routines_regionPlaceholder"]()} />
</div> </div>
</div> </div>
<div class="flex items-center justify-end gap-2">
<Button type="button" size="sm" variant="outline" onclick={() => (showStepForm = false)}>{m.common_cancel()}</Button>
<Button type="submit" size="sm">{m["routines_addStepBtn"]()}</Button> <Button type="submit" size="sm">{m["routines_addStepBtn"]()}</Button>
</div>
</form> </form>
</CardContent> </FormSectionCard>
</Card>
{/if} {/if}
{#if steps.length} {#if steps.length}
@ -229,7 +256,7 @@
value={editDraft.product_id ?? ''} value={editDraft.product_id ?? ''}
onChange={(value) => (editDraft.product_id = value || undefined)} onChange={(value) => (editDraft.product_id = value || undefined)}
/> />
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="space-y-1"> <div class="space-y-1">
<Label>{m.routines_dose()}</Label> <Label>{m.routines_dose()}</Label>
<Input <Input
@ -330,16 +357,15 @@
{/if} {/if}
</div> </div>
<Separator class="opacity-50" /> <FormSectionCard title={m.common_edit()} className="reveal-3" contentClassName="space-y-4">
<p class="text-sm text-muted-foreground">{m["routines_confirmDelete"]()}</p>
<form <form
method="POST" method="POST"
action="?/delete" action="?/delete"
use:enhance use:enhance
onsubmit={(e) => { onsubmit={(e) => preventIfNotConfirmed(e, m["routines_confirmDelete"]())}
if (!confirm(m["routines_confirmDelete"]())) e.preventDefault();
}}
> >
<Button type="submit" variant="destructive" size="sm">{m["routines_deleteRoutine"]()}</Button> <Button type="submit" variant="destructive" size="sm">{m["routines_deleteRoutine"]()}</Button>
</form> </form>
</FormSectionCard>
</div> </div>

View file

@ -1,5 +1,4 @@
import { import {
createGroomingScheduleEntry,
deleteGroomingScheduleEntry, deleteGroomingScheduleEntry,
getGroomingSchedule, getGroomingSchedule,
updateGroomingScheduleEntry updateGroomingScheduleEntry
@ -13,27 +12,6 @@ export const load: PageServerLoad = async () => {
}; };
export const actions: Actions = { export const actions: Actions = {
create: async ({ request }) => {
const form = await request.formData();
const day_of_week = form.get('day_of_week');
const action = form.get('action') as string;
if (day_of_week === null || !action) {
return fail(400, { error: 'day_of_week and action are required' });
}
const body: Record<string, unknown> = {
day_of_week: Number(day_of_week),
action
};
const notes = (form.get('notes') as string)?.trim();
if (notes) body.notes = notes;
try {
await createGroomingScheduleEntry(body);
return { created: true };
} catch (e) {
return fail(500, { error: (e as Error).message });
}
},
update: async ({ request }) => { update: async ({ request }) => {
const form = await request.formData(); const form = await request.formData();
const id = form.get('id') as string; const id = form.get('id') as string;

View file

@ -2,20 +2,20 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { resolve } from '$app/paths'; 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 * as m from '$lib/paraglide/messages.js';
import FlashMessages from '$lib/components/FlashMessages.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
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 { Card, CardContent } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import { ArrowLeft } from 'lucide-svelte';
import type { GroomingAction, GroomingSchedule } from '$lib/types'; import type { GroomingAction, GroomingSchedule } from '$lib/types';
import { preventIfNotConfirmed } from '$lib/utils/forms';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
let { schedule } = $derived(data); let { schedule } = $derived(data);
let showAddForm = $state(false);
let editingId = $state<string | null>(null); let editingId = $state<string | null>(null);
const DAY_NAMES = $derived([ const DAY_NAMES = $derived([
@ -35,6 +35,13 @@
} as Record<GroomingAction, string>); } as Record<GroomingAction, string>);
const ALL_ACTIONS: GroomingAction[] = ['shaving_razor', 'shaving_oneblade', 'dermarolling']; const ALL_ACTIONS: GroomingAction[] = ['shaving_razor', 'shaving_oneblade', 'dermarolling'];
const actionOptions = $derived(ALL_ACTIONS.map((action) => ({ value: action, label: ACTION_LABELS[action] })));
const dayOptions = $derived(DAY_NAMES.map((name, idx) => ({ value: String(idx), label: name })));
const flashMessages = $derived([
...(form?.error ? [{ kind: 'error' as const, text: form.error }] : []),
...(form?.updated ? [{ kind: 'success' as const, text: m["grooming_entryUpdated"]() }] : []),
...(form?.deleted ? [{ kind: 'success' as const, text: m["grooming_entryDeleted"]() }] : [])
]);
const byDay = $derived( const byDay = $derived(
DAY_NAMES.map((name, idx) => ({ DAY_NAMES.map((name, idx) => ({
@ -48,85 +55,30 @@
<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-hero reveal-1 space-y-3"> <PageHeader
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["grooming_backToRoutines"]()}</a> title={m.grooming_title()}
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> kicker={m["nav_appSubtitle"]()}
<h2 class="editorial-title text-[clamp(1.8rem,3vw,2.4rem)]">{m.grooming_title()}</h2> backHref={resolve('/routines')}
<div class="editorial-toolbar"> backLabel={m["grooming_backToRoutines"]()}
<Button variant="outline" size="sm" onclick={() => (showAddForm = !showAddForm)}> titleClassName="editorial-title text-[clamp(1.8rem,3vw,2.4rem)]"
{showAddForm ? m.common_cancel() : m["grooming_addEntry"]()} >
</Button> {#snippet actions()}
</div> <Button variant="outline" size="sm" href="/routines/grooming-schedule/new">{m["grooming_addEntry"]()}</Button>
</section> {/snippet}
{#if form?.error} <div class="hero-strip" aria-label={m.grooming_title()}>
<div class="editorial-alert editorial-alert--error">{form.error}</div> <div>
{/if} <p class="hero-strip-label">{m["grooming_dayOfWeek"]()}</p>
{#if form?.created} <p class="hero-strip-value">{byDay.length}</p>
<div class="editorial-alert editorial-alert--success">{m["grooming_entryAdded"]()}</div> </div>
{/if} <div>
{#if form?.updated} <p class="hero-strip-label">{m["grooming_addEntry"]()}</p>
<div class="editorial-alert editorial-alert--success">{m["grooming_entryUpdated"]()}</div> <p class="hero-strip-value">{schedule.length}</p>
{/if} </div>
{#if form?.deleted} </div>
<div class="editorial-alert editorial-alert--success">{m["grooming_entryDeleted"]()}</div> </PageHeader>
{/if}
<!-- Add form --> <FlashMessages messages={flashMessages} />
{#if showAddForm}
<Card>
<CardContent class="pt-4">
<form
method="POST"
action="?/create"
use:enhance={() => {
return async ({ update }) => {
await update();
showAddForm = false;
};
}}
class="grid grid-cols-2 gap-4"
>
<div class="space-y-1">
<Label for="add_day">{m["grooming_dayOfWeek"]()}</Label>
<select
id="add_day"
name="day_of_week"
required
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{#each DAY_NAMES as name, idx (idx)}
<option value={idx}>{name}</option>
{/each}
</select>
</div>
<div class="space-y-1">
<Label for="add_action">{m.grooming_action()}</Label>
<select
id="add_action"
name="action"
required
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{#each ALL_ACTIONS as action (action)}
<option value={action}>{ACTION_LABELS[action]}</option>
{/each}
</select>
</div>
<div class="col-span-2 space-y-1">
<Label for="add_notes">{m["grooming_notesOptional"]()}</Label>
<Input id="add_notes" name="notes" placeholder={m["grooming_notesPlaceholder"]()} />
</div>
<div class="col-span-2 flex gap-2">
<Button type="submit" size="sm">{m.common_add()}</Button>
<Button type="button" variant="ghost" size="sm" onclick={() => (showAddForm = false)}>
{m.common_cancel()}
</Button>
</div>
</form>
</CardContent>
</Card>
{/if}
<!-- Entries grouped by day --> <!-- Entries grouped by day -->
{#if schedule.length === 0} {#if schedule.length === 0}
@ -158,9 +110,7 @@
method="POST" method="POST"
action="?/delete" action="?/delete"
use:enhance use:enhance
onsubmit={(e) => { onsubmit={(e) => preventIfNotConfirmed(e, m["grooming_confirmDelete"]())}
if (!confirm(m["grooming_confirmDelete"]())) e.preventDefault();
}}
> >
<input type="hidden" name="id" value={entry.id} /> <input type="hidden" name="id" value={entry.id} />
<Button variant="ghost" size="sm" type="submit" class="text-destructive hover:text-destructive"> <Button variant="ghost" size="sm" type="submit" class="text-destructive hover:text-destructive">
@ -185,31 +135,11 @@
class="grid grid-cols-2 gap-3" class="grid grid-cols-2 gap-3"
> >
<input type="hidden" name="id" value={entry.id} /> <input type="hidden" name="id" value={entry.id} />
<div class="space-y-1"> <SimpleSelect id={`edit_day_${entry.id}`} name="day_of_week" label={m["grooming_dayOfWeek"]()} options={dayOptions} value={String(entry.day_of_week)} />
<Label>{m["grooming_dayOfWeek"]()}</Label> <SimpleSelect id={`edit_action_${entry.id}`} name="action" label={m.grooming_action()} options={actionOptions} value={entry.action} />
<select
name="day_of_week"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{#each DAY_NAMES as dayName, idx (idx)}
<option value={idx} selected={entry.day_of_week === idx}>{dayName}</option>
{/each}
</select>
</div>
<div class="space-y-1">
<Label>{m.grooming_action()}</Label>
<select
name="action"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{#each ALL_ACTIONS as a (a)}
<option value={a} selected={entry.action === a}>{ACTION_LABELS[a]}</option>
{/each}
</select>
</div>
<div class="col-span-2 space-y-1"> <div class="col-span-2 space-y-1">
<Label>{m.inventory_notes()}</Label> <label class="text-sm font-medium" for={`edit_notes_${entry.id}`}>{m.inventory_notes()}</label>
<Input name="notes" value={entry.notes ?? ''} placeholder={m["common_optional_notes"]()} /> <Input id={`edit_notes_${entry.id}`} name="notes" value={entry.notes ?? ''} placeholder={m["common_optional_notes"]()} />
</div> </div>
<div class="col-span-2 flex gap-2"> <div class="col-span-2 flex gap-2">
<Button type="submit" size="sm">{m.common_save()}</Button> <Button type="submit" size="sm">{m.common_save()}</Button>

View file

@ -0,0 +1,34 @@
import { createGroomingScheduleEntry } from '$lib/api';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return {};
};
export const actions: Actions = {
default: async ({ request }) => {
const form = await request.formData();
const day_of_week = form.get('day_of_week');
const action = form.get('action') as string;
if (day_of_week === null || !action) {
return fail(400, { error: 'day_of_week and action are required' });
}
const body: Record<string, unknown> = {
day_of_week: Number(day_of_week),
action
};
const notes = (form.get('notes') as string)?.trim();
if (notes) body.notes = notes;
try {
await createGroomingScheduleEntry(body);
} catch (error) {
return fail(500, { error: (error as Error).message });
}
redirect(303, '/routines/grooming-schedule');
}
};

View file

@ -0,0 +1,65 @@
<script lang="ts">
import { resolve } from '$app/paths';
import FlashMessages from '$lib/components/FlashMessages.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import * as m from '$lib/paraglide/messages.js';
import type { GroomingAction } from '$lib/types';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
const dayNames = [
m['grooming_dayMonday'](),
m['grooming_dayTuesday'](),
m['grooming_dayWednesday'](),
m['grooming_dayThursday'](),
m['grooming_dayFriday'](),
m['grooming_daySaturday'](),
m['grooming_daySunday']()
];
const actionLabels: Record<GroomingAction, string> = {
shaving_razor: m['grooming_actionShavingRazor'](),
shaving_oneblade: m['grooming_actionShavingOneblade'](),
dermarolling: m['grooming_actionDermarolling']()
};
const allActions: GroomingAction[] = ['shaving_razor', 'shaving_oneblade', 'dermarolling'];
const dayOptions = dayNames.map((name, index) => ({ value: String(index), label: name }));
const actionOptions = allActions.map((action) => ({ value: action, label: actionLabels[action] }));
const flashMessages = $derived(form?.error ? [{ kind: 'error' as const, text: form.error }] : []);
</script>
<svelte:head><title>{m['grooming_newTitle']()} — innercontext</title></svelte:head>
<div class="editorial-page space-y-4">
<PageHeader
title={m['grooming_newTitle']()}
kicker={m['nav_appSubtitle']()}
backHref={resolve('/routines/grooming-schedule')}
backLabel={m.grooming_title()}
subtitle={m['grooming_newSubtitle']()}
/>
<FlashMessages messages={flashMessages} />
<Card class="reveal-2">
<CardContent class="pt-4">
<form method="POST" class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<p class="sm:col-span-2 text-sm leading-6 text-muted-foreground">{m['grooming_newSectionIntro']()}</p>
<SimpleSelect id="day_of_week" name="day_of_week" label={m['grooming_dayOfWeek']()} options={dayOptions} required />
<SimpleSelect id="action" name="action" label={m.grooming_action()} options={actionOptions} required />
<div class="space-y-1 sm:col-span-2">
<label class="text-sm font-medium" for="notes">{m['grooming_notesOptional']()}</label>
<Input id="notes" name="notes" placeholder={m['grooming_notesPlaceholder']()} />
</div>
<div class="flex items-center justify-end gap-2 sm:col-span-2">
<Button type="button" variant="outline" href={resolve('/routines/grooming-schedule')}>{m.common_cancel()}</Button>
<Button type="submit">{m.common_add()}</Button>
</div>
</form>
</CardContent>
</Card>
</div>

View file

@ -2,9 +2,9 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { resolve } from '$app/paths'; 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 * as m from '$lib/paraglide/messages.js';
import PageHeader from '$lib/components/PageHeader.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { ArrowLeft } from 'lucide-svelte';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte'; import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
@ -22,11 +22,12 @@
<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-hero reveal-1 space-y-3"> <PageHeader
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a> title={m["routines_newTitle"]()}
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> kicker={m["nav_appSubtitle"]()}
<h2 class="editorial-title">{m["routines_newTitle"]()}</h2> backHref={resolve('/routines')}
</section> backLabel={m["routines_backToList"]()}
/>
{#if form?.error} {#if form?.error}
<div class="editorial-alert editorial-alert--error">{form.error}</div> <div class="editorial-alert editorial-alert--error">{form.error}</div>

View file

@ -4,7 +4,8 @@
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { BatchSuggestion, RoutineSuggestion, SuggestedStep } from '$lib/types'; import type { BatchSuggestion, RoutineSuggestion, SuggestedStep } from '$lib/types';
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import PageHeader from '$lib/components/PageHeader.svelte';
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 { Card, CardContent } from '$lib/components/ui/card'; import { Card, CardContent } from '$lib/components/ui/card';
@ -15,7 +16,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, Sparkles } from 'lucide-svelte'; import { ChevronUp, ChevronDown, Sparkles } from 'lucide-svelte';
import ValidationWarningsAlert from '$lib/components/ValidationWarningsAlert.svelte'; import ValidationWarningsAlert from '$lib/components/ValidationWarningsAlert.svelte';
import StructuredErrorDisplay from '$lib/components/StructuredErrorDisplay.svelte'; import StructuredErrorDisplay from '$lib/components/StructuredErrorDisplay.svelte';
import AutoFixBadge from '$lib/components/AutoFixBadge.svelte'; import AutoFixBadge from '$lib/components/AutoFixBadge.svelte';
@ -119,11 +120,12 @@
<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-hero reveal-1 space-y-3"> <PageHeader
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["suggest_backToRoutines"]()}</a> title={m.suggest_title()}
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> kicker={m["nav_appSubtitle"]()}
<h2 class="editorial-title">{m.suggest_title()}</h2> backHref={resolve('/routines')}
</section> backLabel={m["suggest_backToRoutines"]()}
/>
{#if errorMsg} {#if errorMsg}
<StructuredErrorDisplay error={errorMsg} /> <StructuredErrorDisplay error={errorMsg} />
@ -138,9 +140,9 @@
<!-- ── Single tab ─────────────────────────────────────────────────── --> <!-- ── Single tab ─────────────────────────────────────────────────── -->
<TabsContent value="single" class="space-y-6 pt-4"> <TabsContent value="single" class="space-y-6 pt-4">
<FormSectionCard title={m["suggest_singleParams"]()}> <FormSectionCard title={m["suggest_singleParams"]()}>
<form method="POST" action="?/suggest" use:enhance={enhanceSingle} class="space-y-4"> <form id="suggest-single-form" method="POST" action="?/suggest" use:enhance={enhanceSingle} class="space-y-4">
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:items-start">
<div class="space-y-2"> <div class="space-y-1">
<Label for="single_date">{m.suggest_date()}</Label> <Label for="single_date">{m.suggest_date()}</Label>
<Input id="single_date" name="routine_date" type="date" value={data.today} required /> <Input id="single_date" name="routine_date" type="date" value={data.today} required />
</div> </div>
@ -284,12 +286,12 @@
<TabsContent value="batch" class="space-y-6 pt-4"> <TabsContent value="batch" class="space-y-6 pt-4">
<FormSectionCard title={m["suggest_batchRange"]()}> <FormSectionCard title={m["suggest_batchRange"]()}>
<form id="batch-form" method="POST" action="?/suggestBatch" use:enhance={enhanceBatch} class="space-y-4"> <form id="batch-form" method="POST" action="?/suggestBatch" use:enhance={enhanceBatch} class="space-y-4">
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:items-start">
<div class="space-y-2"> <div class="space-y-1">
<Label for="from_date">{m["suggest_fromDate"]()}</Label> <Label for="from_date">{m["suggest_fromDate"]()}</Label>
<Input id="from_date" name="from_date" type="date" value={data.today} required /> <Input id="from_date" name="from_date" type="date" value={data.today} required />
</div> </div>
<div class="space-y-2"> <div class="space-y-1">
<Label for="to_date">{m["suggest_toDate"]()}</Label> <Label for="to_date">{m["suggest_toDate"]()}</Label>
<Input id="to_date" name="to_date" type="date" value={data.today} required /> <Input id="to_date" name="to_date" type="date" value={data.today} required />
</div> </div>

View file

@ -1,4 +1,4 @@
import { createSkinSnapshot, deleteSkinSnapshot, getSkinSnapshots, updateSkinSnapshot } from '$lib/api'; import { deleteSkinSnapshot, getSkinSnapshots, updateSkinSnapshot } 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';
@ -9,55 +9,6 @@ export const load: PageServerLoad = async ({ url }) => {
}; };
export const actions: Actions = { export const actions: Actions = {
create: async ({ request }) => {
const form = await request.formData();
const snapshot_date = form.get('snapshot_date') as string;
const overall_state = form.get('overall_state') as string;
const texture = form.get('texture') as string;
const notes = form.get('notes') as string;
const hydration_level = form.get('hydration_level') as string;
const sensitivity_level = form.get('sensitivity_level') as string;
const barrier_state = form.get('barrier_state') as string;
const active_concerns_values = form
.getAll('active_concerns')
.map((value) => String(value).trim())
.filter(Boolean);
const priorities_raw = form.get('priorities') as string;
if (!snapshot_date) {
return fail(400, { error: 'Date is required' });
}
const active_concerns = active_concerns_values;
const priorities = priorities_raw
?.split(',')
.map((p) => p.trim())
.filter(Boolean) ?? [];
const skin_type = form.get('skin_type') as string;
const sebum_tzone = form.get('sebum_tzone') as string;
const sebum_cheeks = form.get('sebum_cheeks') as string;
const body: Record<string, unknown> = { snapshot_date, active_concerns, priorities };
if (overall_state) body.overall_state = overall_state;
if (texture) body.texture = texture;
if (notes) body.notes = notes;
if (hydration_level) body.hydration_level = Number(hydration_level);
if (sensitivity_level) body.sensitivity_level = Number(sensitivity_level);
if (barrier_state) body.barrier_state = barrier_state;
if (skin_type) body.skin_type = skin_type;
if (sebum_tzone) body.sebum_tzone = Number(sebum_tzone);
if (sebum_cheeks) body.sebum_cheeks = Number(sebum_cheeks);
try {
await createSkinSnapshot(body);
return { created: true };
} catch (e) {
return fail(500, { error: (e as Error).message });
}
},
update: async ({ request }) => { update: async ({ request }) => {
const form = await request.formData(); const form = await request.formData();
const id = form.get('id') as string; const id = form.get('id') as string;

View file

@ -1,14 +1,17 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import FlashMessages from '$lib/components/FlashMessages.svelte';
import type { OverallSkinState } from '$lib/types';
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { analyzeSkinPhotos } from '$lib/api'; import PageHeader from '$lib/components/PageHeader.svelte';
import { getOverallSkinStateLabel, getSkinConcernLabel } from '$lib/utils/skin-display';
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 { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent } from '$lib/components/ui/card';
import LabeledInputField from '$lib/components/forms/LabeledInputField.svelte'; import LabeledInputField from '$lib/components/forms/LabeledInputField.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte'; import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { Sparkles, Pencil, X } from 'lucide-svelte'; import { Pencil, X } from 'lucide-svelte';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
@ -36,13 +39,6 @@
poor: 'state-pill state-pill--poor' poor: 'state-pill state-pill--poor'
}; };
const stateLabels: Record<string, () => string> = {
excellent: m["skin_stateExcellent"],
good: m["skin_stateGood"],
fair: m["skin_stateFair"],
poor: m["skin_statePoor"]
};
const textureLabels: Record<string, () => string> = { const textureLabels: Record<string, () => string> = {
smooth: m["skin_textureSmooth"], smooth: m["skin_textureSmooth"],
rough: m["skin_textureRough"], rough: m["skin_textureRough"],
@ -65,36 +61,6 @@
acne_prone: m["skin_typeAcneProne"] acne_prone: m["skin_typeAcneProne"]
}; };
const concernLabels: Record<string, () => string> = {
acne: m["productForm_concernAcne"],
rosacea: m["productForm_concernRosacea"],
hyperpigmentation: m["productForm_concernHyperpigmentation"],
aging: m["productForm_concernAging"],
dehydration: m["productForm_concernDehydration"],
redness: m["productForm_concernRedness"],
damaged_barrier: m["productForm_concernDamagedBarrier"],
pore_visibility: m["productForm_concernPoreVisibility"],
uneven_texture: m["productForm_concernUnevenTexture"],
hair_growth: m["productForm_concernHairGrowth"],
sebum_excess: m["productForm_concernSebumExcess"]
};
let showForm = $state(false);
// Create form state (bound to inputs so AI can pre-fill)
let snapshotDate = $state(new Date().toISOString().slice(0, 10));
let overallState = $state('');
let texture = $state('');
let barrierState = $state('');
let skinType = $state('');
let hydrationLevel = $state('');
let sensitivityLevel = $state('');
let sebumTzone = $state('');
let sebumCheeks = $state('');
let activeConcerns = $state<string[]>([]);
let prioritiesRaw = $state('');
let notes = $state('');
// Edit state // Edit state
let editingId = $state<string | null>(null); let editingId = $state<string | null>(null);
let editSnapshotDate = $state(''); let editSnapshotDate = $state('');
@ -124,292 +90,63 @@
editActiveConcerns = [...(snap.active_concerns ?? [])]; editActiveConcerns = [...(snap.active_concerns ?? [])];
editPrioritiesRaw = snap.priorities?.join(', ') ?? ''; editPrioritiesRaw = snap.priorities?.join(', ') ?? '';
editNotes = snap.notes ?? ''; editNotes = snap.notes ?? '';
showForm = false;
} }
// AI photo analysis state const overallStateOptions = $derived(
let aiModalOpen = $state(false); states.map((s) => ({ value: s, label: getOverallSkinStateLabel(s as OverallSkinState) }))
let selectedFiles = $state<File[]>([]); );
let previewUrls = $state<string[]>([]);
let aiLoading = $state(false);
let aiError = $state('');
const overallStateOptions = $derived(states.map((s) => ({ value: s, label: stateLabels[s]?.() ?? s })));
const textureOptions = $derived(skinTextures.map((t) => ({ value: t, label: textureLabels[t]?.() ?? t }))); const textureOptions = $derived(skinTextures.map((t) => ({ value: t, label: textureLabels[t]?.() ?? t })));
const skinTypeOptions = $derived(skinTypes.map((st) => ({ value: st, label: skinTypeLabels[st]?.() ?? st }))); const skinTypeOptions = $derived(skinTypes.map((st) => ({ value: st, label: skinTypeLabels[st]?.() ?? st })));
const barrierOptions = $derived(barrierStates.map((b) => ({ value: b, label: barrierLabels[b]?.() ?? b }))); const barrierOptions = $derived(barrierStates.map((b) => ({ value: b, label: barrierLabels[b]?.() ?? b })));
const activeConcernOptions = $derived( const activeConcernOptions = $derived(
activeConcernValues.map((value) => ({ activeConcernValues.map((value) => ({
value, value,
label: concernLabels[value]?.() ?? value.replace(/_/g, ' ') label: getSkinConcernLabel(value)
})) }))
); );
const sortedSnapshots = $derived( const sortedSnapshots = $derived(
[...data.snapshots].sort((a, b) => b.snapshot_date.localeCompare(a.snapshot_date)) [...data.snapshots].sort((a, b) => b.snapshot_date.localeCompare(a.snapshot_date))
); );
const latestSnapshot = $derived(sortedSnapshots[0] ?? null);
function handleFileSelect(e: Event) { const flashMessages = $derived([
const input = e.target as HTMLInputElement; ...(form?.error ? [{ kind: 'error' as const, text: form.error }] : []),
const files = Array.from(input.files ?? []).slice(0, 3); ...(form?.updated ? [{ kind: 'success' as const, text: m['skin_snapshotUpdated']() }] : []),
selectedFiles = files; ...(form?.deleted ? [{ kind: 'success' as const, text: m['skin_snapshotDeleted']() }] : [])
previewUrls.forEach(URL.revokeObjectURL); ]);
previewUrls = files.map((f) => URL.createObjectURL(f));
}
async function analyzePhotos() {
if (!selectedFiles.length) return;
aiLoading = true;
aiError = '';
try {
const r = await analyzeSkinPhotos(selectedFiles);
if (r.overall_state) overallState = r.overall_state;
if (r.texture) texture = r.texture;
if (r.skin_type) skinType = r.skin_type;
if (r.barrier_state) barrierState = r.barrier_state;
if (r.hydration_level != null) hydrationLevel = String(r.hydration_level);
if (r.sensitivity_level != null) sensitivityLevel = String(r.sensitivity_level);
if (r.sebum_tzone != null) sebumTzone = String(r.sebum_tzone);
if (r.sebum_cheeks != null) sebumCheeks = String(r.sebum_cheeks);
if (r.active_concerns?.length) activeConcerns = [...r.active_concerns];
if (r.priorities?.length) prioritiesRaw = r.priorities.join(', ');
if (r.notes) notes = r.notes;
aiModalOpen = false;
} catch (e) {
aiError = (e as Error).message;
} finally {
aiLoading = false;
}
}
function openAiModal() {
aiError = '';
aiModalOpen = true;
}
function closeAiModal() {
if (aiLoading) return;
aiModalOpen = false;
}
function handleModalKeydown(event: KeyboardEvent) {
if (!aiModalOpen) return;
if (event.key === 'Escape') {
event.preventDefault();
closeAiModal();
}
}
</script> </script>
<svelte:head><title>{m.skin_title()} — innercontext</title></svelte:head> <svelte:head><title>{m.skin_title()} — innercontext</title></svelte:head>
<svelte:window onkeydown={handleModalKeydown} />
<div class="editorial-page space-y-4"> <div class="editorial-page space-y-4">
<section class="editorial-hero reveal-1"> <PageHeader
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p> title={m.skin_title()}
<h2 class="editorial-title">{m.skin_title()}</h2> kicker={m["nav_appSubtitle"]()}
<p class="editorial-subtitle">{m.skin_count({ count: data.snapshots.length })}</p> subtitle={m.skin_count({ count: data.snapshots.length })}
<div class="editorial-toolbar"> >
{#if showForm} {#snippet actions()}
<Button type="button" variant="outline" size="sm" onclick={openAiModal}> <Button variant="outline" href="/skin/new">{m["skin_addNew"]()}</Button>
<Sparkles class="size-4" /> {/snippet}
{m["skin_aiAnalysisTitle"]()}
</Button>
{/if}
<Button variant="outline" onclick={() => (showForm = !showForm)}>
{showForm ? m.common_cancel() : m["skin_addNew"]()}
</Button>
</div>
</section>
{#if form?.error} <div class="hero-strip" aria-label={m.skin_title()}>
<div class="editorial-alert editorial-alert--error">{form.error}</div> <div>
{/if} <p class="hero-strip-label">{m.skin_date()}</p>
{#if form?.created} <p class="hero-strip-value">{latestSnapshot?.snapshot_date ?? '—'}</p>
<div class="editorial-alert editorial-alert--success">{m["skin_snapshotAdded"]()}</div> </div>
{/if} <div>
{#if form?.updated} <p class="hero-strip-label">{m["skin_overallState"]()}</p>
<div class="editorial-alert editorial-alert--success">{m["skin_snapshotUpdated"]()}</div> <p class="hero-strip-value">
{/if} {latestSnapshot?.overall_state ? getOverallSkinStateLabel(latestSnapshot.overall_state) : '—'}
{#if form?.deleted} </p>
<div class="editorial-alert editorial-alert--success">{m["skin_snapshotDeleted"]()}</div> </div>
{/if} <div>
<p class="hero-strip-label">{m["skin_activeConcerns"]()}</p>
<p class="hero-strip-value">{latestSnapshot?.active_concerns.length ?? 0}</p>
</div>
</div>
</PageHeader>
{#if showForm} <FlashMessages messages={flashMessages} />
{#if aiModalOpen}
<button
type="button"
class="fixed inset-0 z-50 bg-black/50"
onclick={closeAiModal}
aria-label={m.common_cancel()}
></button>
<div class="fixed inset-x-3 bottom-3 top-3 z-50 mx-auto flex max-w-2xl items-center md:inset-x-6 md:inset-y-8">
<Card class="max-h-full w-full overflow-hidden">
<CardHeader class="border-b border-border">
<div class="flex items-center justify-between gap-3">
<CardTitle>{m["skin_aiAnalysisTitle"]()}</CardTitle>
<Button type="button" variant="ghost" size="sm" class="h-8 w-8 p-0" onclick={closeAiModal} aria-label={m.common_cancel()}>
<X class="size-4" />
</Button>
</div>
</CardHeader>
<CardContent class="space-y-3 overflow-y-auto p-4">
<p class="text-sm text-muted-foreground">{m["skin_aiUploadText"]()}</p>
<input
type="file"
accept="image/heic,image/heif,image/jpeg,image/png,image/webp"
multiple
onchange={handleFileSelect}
class="block w-full text-sm text-muted-foreground file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground"
/>
{#if previewUrls.length}
<div class="flex flex-wrap gap-2">
{#each previewUrls as url (url)}
<img src={url} alt="skin preview" class="h-24 w-24 rounded-md border object-cover" />
{/each}
</div>
{/if}
{#if aiError}
<p class="text-sm text-destructive">{aiError}</p>
{/if}
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" onclick={closeAiModal} disabled={aiLoading}>{m.common_cancel()}</Button>
<Button type="button" onclick={analyzePhotos} disabled={aiLoading || !selectedFiles.length}>
{aiLoading ? m.skin_analyzing() : m["skin_analyzePhotos"]()}
</Button>
</div>
</CardContent>
</Card>
</div>
{/if}
<!-- New snapshot form -->
<Card class="reveal-2">
<CardHeader><CardTitle>{m["skin_newSnapshotTitle"]()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<LabeledInputField
id="snapshot_date"
name="snapshot_date"
label={m.skin_date()}
type="date"
required
bind:value={snapshotDate}
/>
<SimpleSelect
id="overall_state"
name="overall_state"
label={m["skin_overallState"]()}
options={overallStateOptions}
placeholder={m.common_select()}
bind:value={overallState}
/>
<SimpleSelect
id="texture"
name="texture"
label={m.skin_texture()}
options={textureOptions}
placeholder={m.common_select()}
bind:value={texture}
/>
<SimpleSelect
id="skin_type"
name="skin_type"
label={m["skin_skinType"]()}
options={skinTypeOptions}
placeholder={m.common_select()}
bind:value={skinType}
/>
<SimpleSelect
id="barrier_state"
name="barrier_state"
label={m["skin_barrierState"]()}
options={barrierOptions}
placeholder={m.common_select()}
bind:value={barrierState}
/>
<LabeledInputField
id="hydration_level"
name="hydration_level"
label={m.skin_hydration()}
type="number"
min="1"
max="5"
bind:value={hydrationLevel}
/>
<LabeledInputField
id="sensitivity_level"
name="sensitivity_level"
label={m.skin_sensitivity()}
type="number"
min="1"
max="5"
bind:value={sensitivityLevel}
/>
<LabeledInputField
id="sebum_tzone"
name="sebum_tzone"
label={m["skin_sebumTzone"]()}
type="number"
min="1"
max="5"
bind:value={sebumTzone}
/>
<LabeledInputField
id="sebum_cheeks"
name="sebum_cheeks"
label={m["skin_sebumCheeks"]()}
type="number"
min="1"
max="5"
bind:value={sebumCheeks}
/>
<div class="space-y-2 col-span-2">
<p class="text-sm font-medium">{m["skin_activeConcerns"]()}</p>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
{#each activeConcernOptions as option (option.value)}
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
name="active_concerns"
value={option.value}
checked={activeConcerns.includes(option.value)}
onchange={() => {
if (activeConcerns.includes(option.value)) {
activeConcerns = activeConcerns.filter((c) => c !== option.value);
} else {
activeConcerns = [...activeConcerns, option.value];
}
}}
class="rounded border-input"
/>
{option.label}
</label>
{/each}
</div>
</div>
<LabeledInputField
id="priorities"
name="priorities"
label={m["skin_priorities"]()}
placeholder={m["skin_prioritiesPlaceholder"]()}
className="space-y-1 col-span-2"
bind:value={prioritiesRaw}
/>
<LabeledInputField
id="notes"
name="notes"
label={m.skin_notes()}
className="space-y-1 col-span-2"
bind:value={notes}
/>
<div class="col-span-2">
<Button type="submit">{m["skin_addSnapshot"]()}</Button>
</div>
</form>
</CardContent>
</Card>
{/if}
<div class="space-y-4 reveal-2"> <div class="space-y-4 reveal-2">
{#each sortedSnapshots as snap (snap.id)} {#each sortedSnapshots as snap (snap.id)}
@ -564,7 +301,7 @@
<div class="flex flex-wrap items-center gap-1.5"> <div class="flex flex-wrap items-center gap-1.5">
{#if snap.overall_state} {#if snap.overall_state}
<span class={statePills[snap.overall_state] ?? 'state-pill'}> <span class={statePills[snap.overall_state] ?? 'state-pill'}>
{stateLabels[snap.overall_state]?.() ?? snap.overall_state} {getOverallSkinStateLabel(snap.overall_state)}
</span> </span>
{/if} {/if}
{#if snap.texture} {#if snap.texture}
@ -596,7 +333,7 @@
{#if snap.active_concerns.length} {#if snap.active_concerns.length}
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#each snap.active_concerns as c (c)} {#each snap.active_concerns as c (c)}
<Badge variant="secondary" class="text-xs">{concernLabels[c]?.() ?? c.replace(/_/g, ' ')}</Badge> <Badge variant="secondary" class="text-xs">{getSkinConcernLabel(c)}</Badge>
{/each} {/each}
</div> </div>
{/if} {/if}

View file

@ -0,0 +1,62 @@
import { createSkinSnapshot } from '$lib/api';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return { today: new Date().toISOString().slice(0, 10) };
};
export const actions: Actions = {
default: async ({ request }) => {
const form = await request.formData();
const snapshot_date = form.get('snapshot_date') as string;
const overall_state = form.get('overall_state') as string;
const texture = form.get('texture') as string;
const notes = form.get('notes') as string;
const hydration_level = form.get('hydration_level') as string;
const sensitivity_level = form.get('sensitivity_level') as string;
const barrier_state = form.get('barrier_state') as string;
const active_concerns_values = form
.getAll('active_concerns')
.map((value) => String(value).trim())
.filter(Boolean);
const priorities_raw = form.get('priorities') as string;
if (!snapshot_date) {
return fail(400, { error: 'Date is required' });
}
const priorities =
priorities_raw
?.split(',')
.map((priority) => priority.trim())
.filter(Boolean) ?? [];
const skin_type = form.get('skin_type') as string;
const sebum_tzone = form.get('sebum_tzone') as string;
const sebum_cheeks = form.get('sebum_cheeks') as string;
const body: Record<string, unknown> = {
snapshot_date,
active_concerns: active_concerns_values,
priorities
};
if (overall_state) body.overall_state = overall_state;
if (texture) body.texture = texture;
if (notes) body.notes = notes;
if (hydration_level) body.hydration_level = Number(hydration_level);
if (sensitivity_level) body.sensitivity_level = Number(sensitivity_level);
if (barrier_state) body.barrier_state = barrier_state;
if (skin_type) body.skin_type = skin_type;
if (sebum_tzone) body.sebum_tzone = Number(sebum_tzone);
if (sebum_cheeks) body.sebum_cheeks = Number(sebum_cheeks);
try {
await createSkinSnapshot(body);
} catch (error) {
return fail(500, { error: (error as Error).message });
}
redirect(303, '/skin');
}
};

View file

@ -0,0 +1,267 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { analyzeSkinPhotos } from '$lib/api';
import FlashMessages from '$lib/components/FlashMessages.svelte';
import LabeledInputField from '$lib/components/forms/LabeledInputField.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import * as m from '$lib/paraglide/messages.js';
import type { OverallSkinState } from '$lib/types';
import { getOverallSkinStateLabel, getSkinConcernLabel } from '$lib/utils/skin-display';
import type { ActionData, PageData } from './$types';
import { Sparkles, X } from 'lucide-svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
const states = ['excellent', 'good', 'fair', 'poor'];
const skinTextures = ['smooth', 'rough', 'flaky', 'bumpy'];
const barrierStates = ['intact', 'mildly_compromised', 'compromised'];
const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone'];
const activeConcernValues = [
'acne',
'rosacea',
'hyperpigmentation',
'aging',
'dehydration',
'redness',
'damaged_barrier',
'pore_visibility',
'uneven_texture',
'sebum_excess'
];
const textureLabels: Record<string, () => string> = {
smooth: m['skin_textureSmooth'],
rough: m['skin_textureRough'],
flaky: m['skin_textureFlaky'],
bumpy: m['skin_textureBumpy']
};
const barrierLabels: Record<string, () => string> = {
intact: m['skin_barrierIntact'],
mildly_compromised: m['skin_barrierMildly'],
compromised: m['skin_barrierCompromised']
};
const skinTypeLabels: Record<string, () => string> = {
dry: m['skin_typeDry'],
oily: m['skin_typeOily'],
combination: m['skin_typeCombination'],
sensitive: m['skin_typeSensitive'],
normal: m['skin_typeNormal'],
acne_prone: m['skin_typeAcneProne']
};
let snapshotDate = $state('');
let overallState = $state('');
let texture = $state('');
let barrierState = $state('');
let skinType = $state('');
let hydrationLevel = $state('');
let sensitivityLevel = $state('');
let sebumTzone = $state('');
let sebumCheeks = $state('');
let activeConcerns = $state<string[]>([]);
let prioritiesRaw = $state('');
let notes = $state('');
let aiModalOpen = $state(false);
let selectedFiles = $state<File[]>([]);
let previewUrls = $state<string[]>([]);
let aiLoading = $state(false);
let aiError = $state('');
const flashMessages = $derived(form?.error ? [{ kind: 'error' as const, text: form.error }] : []);
$effect(() => {
if (!snapshotDate) {
snapshotDate = data.today;
}
});
const overallStateOptions = $derived(
states.map((state) => ({ value: state, label: getOverallSkinStateLabel(state as OverallSkinState) }))
);
const textureOptions = $derived(skinTextures.map((entry) => ({ value: entry, label: textureLabels[entry]?.() ?? entry })));
const skinTypeOptions = $derived(skinTypes.map((entry) => ({ value: entry, label: skinTypeLabels[entry]?.() ?? entry })));
const barrierOptions = $derived(barrierStates.map((entry) => ({ value: entry, label: barrierLabels[entry]?.() ?? entry })));
const activeConcernOptions = $derived(
activeConcernValues.map((value) => ({ value, label: getSkinConcernLabel(value) }))
);
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []).slice(0, 3);
selectedFiles = files;
previewUrls.forEach(URL.revokeObjectURL);
previewUrls = files.map((file) => URL.createObjectURL(file));
}
async function analyzePhotos() {
if (!selectedFiles.length) return;
aiLoading = true;
aiError = '';
try {
const result = await analyzeSkinPhotos(selectedFiles);
if (result.overall_state) overallState = result.overall_state;
if (result.texture) texture = result.texture;
if (result.skin_type) skinType = result.skin_type;
if (result.barrier_state) barrierState = result.barrier_state;
if (result.hydration_level != null) hydrationLevel = String(result.hydration_level);
if (result.sensitivity_level != null) sensitivityLevel = String(result.sensitivity_level);
if (result.sebum_tzone != null) sebumTzone = String(result.sebum_tzone);
if (result.sebum_cheeks != null) sebumCheeks = String(result.sebum_cheeks);
if (result.active_concerns?.length) activeConcerns = [...result.active_concerns];
if (result.priorities?.length) prioritiesRaw = result.priorities.join(', ');
if (result.notes) notes = result.notes;
aiModalOpen = false;
} catch (error) {
aiError = (error as Error).message;
} finally {
aiLoading = false;
}
}
function openAiModal() {
aiError = '';
aiModalOpen = true;
}
function closeAiModal() {
if (aiLoading) return;
aiModalOpen = false;
}
function handleModalKeydown(event: KeyboardEvent) {
if (!aiModalOpen) return;
if (event.key === 'Escape') {
event.preventDefault();
closeAiModal();
}
}
</script>
<svelte:head><title>{m['skin_newSnapshotTitle']()} — innercontext</title></svelte:head>
<svelte:window onkeydown={handleModalKeydown} />
<div class="editorial-page space-y-4">
<PageHeader
title={m['skin_newSnapshotTitle']()}
kicker={m['nav_appSubtitle']()}
backHref={resolve('/skin')}
backLabel={m.skin_title()}
subtitle={m['skin_newSubtitle']()}
/>
<FlashMessages messages={flashMessages} />
{#if aiModalOpen}
<button
type="button"
class="fixed inset-0 z-50 bg-black/50"
onclick={closeAiModal}
aria-label={m.common_cancel()}
></button>
<div class="fixed inset-x-3 bottom-3 top-3 z-50 mx-auto flex max-w-2xl items-center md:inset-x-6 md:inset-y-8">
<Card class="max-h-full w-full overflow-hidden">
<CardHeader class="border-b border-border">
<div class="flex items-center justify-between gap-3">
<CardTitle>{m['skin_aiAnalysisTitle']()}</CardTitle>
<Button type="button" variant="ghost" size="sm" class="h-8 w-8 p-0" onclick={closeAiModal} aria-label={m.common_cancel()}>
<X class="size-4" />
</Button>
</div>
</CardHeader>
<CardContent class="space-y-3 overflow-y-auto p-4">
<p class="text-sm text-muted-foreground">{m['skin_aiUploadText']()}</p>
<input
type="file"
accept="image/heic,image/heif,image/jpeg,image/png,image/webp"
multiple
onchange={handleFileSelect}
class="block w-full text-sm text-muted-foreground file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground"
/>
{#if previewUrls.length}
<div class="flex flex-wrap gap-2">
{#each previewUrls as url (url)}
<img src={url} alt="skin preview" class="h-24 w-24 rounded-md border object-cover" />
{/each}
</div>
{/if}
{#if aiError}
<p class="text-sm text-destructive">{aiError}</p>
{/if}
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" onclick={closeAiModal} disabled={aiLoading}>{m.common_cancel()}</Button>
<Button type="button" onclick={analyzePhotos} disabled={aiLoading || !selectedFiles.length}>
{aiLoading ? m.skin_analyzing() : m['skin_analyzePhotos']()}
</Button>
</div>
</CardContent>
</Card>
</div>
{/if}
<Card class="reveal-2">
<CardHeader><CardTitle>{m['skin_newSnapshotTitle']()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<p class="col-span-2 text-sm leading-6 text-muted-foreground">{m['skin_newSectionIntro']()}</p>
<div class="col-span-2 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-dashed border-border/80 bg-muted/20 px-3 py-3">
<div class="space-y-1">
<p class="text-sm font-medium">{m['skin_aiAnalysisTitle']()}</p>
<p class="text-sm text-muted-foreground">{m['skin_aiUploadText']()}</p>
</div>
<Button type="button" variant="outline" size="sm" onclick={openAiModal}>
<Sparkles class="size-4" />
{m['skin_analyzePhotos']()}
</Button>
</div>
<LabeledInputField id="snapshot_date" name="snapshot_date" label={m.skin_date()} type="date" required bind:value={snapshotDate} />
<SimpleSelect id="overall_state" name="overall_state" label={m['skin_overallState']()} options={overallStateOptions} placeholder={m.common_select()} bind:value={overallState} />
<SimpleSelect id="texture" name="texture" label={m.skin_texture()} options={textureOptions} placeholder={m.common_select()} bind:value={texture} />
<SimpleSelect id="skin_type" name="skin_type" label={m['skin_skinType']()} options={skinTypeOptions} placeholder={m.common_select()} bind:value={skinType} />
<SimpleSelect id="barrier_state" name="barrier_state" label={m['skin_barrierState']()} options={barrierOptions} placeholder={m.common_select()} bind:value={barrierState} />
<LabeledInputField id="hydration_level" name="hydration_level" label={m.skin_hydration()} type="number" min="1" max="5" bind:value={hydrationLevel} />
<LabeledInputField id="sensitivity_level" name="sensitivity_level" label={m.skin_sensitivity()} type="number" min="1" max="5" bind:value={sensitivityLevel} />
<LabeledInputField id="sebum_tzone" name="sebum_tzone" label={m['skin_sebumTzone']()} type="number" min="1" max="5" bind:value={sebumTzone} />
<LabeledInputField id="sebum_cheeks" name="sebum_cheeks" label={m['skin_sebumCheeks']()} type="number" min="1" max="5" bind:value={sebumCheeks} />
<div class="col-span-2 space-y-2">
<p class="text-sm font-medium">{m['skin_activeConcerns']()}</p>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
{#each activeConcernOptions as option (option.value)}
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
name="active_concerns"
value={option.value}
checked={activeConcerns.includes(option.value)}
onchange={() => {
if (activeConcerns.includes(option.value)) {
activeConcerns = activeConcerns.filter((entry) => entry !== option.value);
} else {
activeConcerns = [...activeConcerns, option.value];
}
}}
class="rounded border-input"
/>
{option.label}
</label>
{/each}
</div>
</div>
<LabeledInputField id="priorities" name="priorities" label={m['skin_priorities']()} placeholder={m['skin_prioritiesPlaceholder']()} className="col-span-2 space-y-1" bind:value={prioritiesRaw} />
<LabeledInputField id="notes" name="notes" label={m.skin_notes()} className="col-span-2 space-y-1" bind:value={notes} />
<div class="col-span-2 flex items-center justify-end gap-2">
<Button type="button" variant="outline" href={resolve('/skin')}>{m.common_cancel()}</Button>
<Button type="submit">{m['skin_addSnapshot']()}</Button>
</div>
</form>
</CardContent>
</Card>
</div>