Compare commits

...

51 commits

Author SHA1 Message Date
1d8a8eafb8 refactor(api): remove MCP server integration and docs references 2026-03-04 12:28:30 +01:00
5dd8242985 fix(routines): simplify inventory preference in system prompt 2026-03-04 12:18:07 +01:00
b58fcb1440 feat(api): add tool-calling flow for shopping suggestions
Keep /products/suggest lean by exposing product UUIDs and fetching INCI, safety rules, actives, and usage notes on demand through Gemini function tools. Add conservative fallback behavior for tool roundtrip limits and expand helper tests to cover tool wiring and payload handlers.
2026-03-04 12:05:33 +01:00
558708653c feat(api): expand routines tool-calling to reduce prompt load
Keep the /routines/suggest base context lean by sending only active names and fetching detailed safety, actives, usage notes, and INCI on demand. Add a conservative fallback when tool roundtrip limits are hit to preserve safe outputs instead of failing the request.
2026-03-04 11:52:07 +01:00
cfd2485b7e feat(api): add INCI tool-calling with normalized tool traces
Enable on-demand INCI retrieval in /routines/suggest through Gemini function calling so detailed ingredient data is fetched only when needed. Persist and normalize tool_trace data in AI logs to make function-call behavior directly inspectable via /ai-logs endpoints.
2026-03-04 11:35:19 +01:00
c0eeb0425d fix(routines): include product safety and usage signals in prompts
Expose leave-on behavior, contraindications, safety alerts, and compact usage notes in AVAILABLE PRODUCTS so Gemini can make safer routine decisions with real-world product constraints.
2026-03-04 02:42:16 +01:00
9bbc34ffd2 test(api): fix ruff issues in routine tests 2026-03-04 02:23:19 +01:00
472a3034a0 feat(routines): refine therapeutic and travel-mode prompt rules 2026-03-04 02:22:39 +01:00
a7ad956a62 fix(frontend): correct plural forms for count labels 2026-03-04 01:30:56 +01:00
820d58ea37 feat(routines): enrich single AI suggestions with concise context 2026-03-04 01:22:57 +01:00
083cd055fb chore(backend): exclude dev and editable installs in deploy sync 2026-03-03 23:21:12 +01:00
a27471a19a docs: add commit guidelines to AGENTS.md 2026-03-03 22:07:39 +01:00
88f3642387 test(api): add tests for ai suggestion endpoints and helpers 2026-03-03 22:06:33 +01:00
5ad9b66a21 build(backend): add pytest-cov configuration and report generation 2026-03-03 22:06:24 +01:00
ba1f10d99f refactor(llm): optimize Gemini config profiles for extraction and creativity
Introduces `get_extraction_config` and `get_creative_config` to standardize Gemini API calls.

* Defines explicit config profiles with appropriate `temperature` and `thinking_level` for Gemini 3 Flash.
* Extraction tasks use minimal thinking and temp=0.0 to reduce latency and token usage.
* Creative tasks use low thinking, temp=0.4, and top_p=0.8 to balance naturalness and safety.
* Applies these helpers across products, routines, and skincare endpoints.
* Also updates default model to `gemini-3-flash-preview`.
2026-03-03 21:24:23 +01:00
78df7322a9 refactor(api): remove shopping assistant logic from mcp_server 2026-03-03 20:51:42 +01:00
067e460dd2 chore(frontend): format files with prettier 2026-03-03 20:51:34 +01:00
0e7a39836f refactor(routines): use category and short uuid for recent history representation 2026-03-03 20:29:36 +01:00
28fb74b9bf refactor(routines): translate prompt input keys to english to reduce language switch penalty 2026-03-03 20:24:56 +01:00
9574c91be1 refactor(routines): remove hardcoded grooming actions from system prompt 2026-03-03 20:22:59 +01:00
4627ec70bf refactor(routines): remove examples from inventory management rule to avoid bias 2026-03-03 20:07:13 +01:00
30ebc093bf feat(routines): adjust inventory management prompt to allow opening better suited sealed products 2026-03-03 20:06:38 +01:00
877051cfaf feat(routines): add actives and recent usage tracking to product context 2026-03-03 20:01:39 +01:00
1109d9f397 fix(products): only suggest when real need exists 2026-03-03 19:51:49 +01:00
d1bdfc4993 docs: replace CLAUDE.md with AGENTS.md and add frontend details 2026-03-03 01:27:01 +01:00
098b158b75 feat(frontend): add ESLint and Prettier with Svelte support
- Install eslint, prettier and related plugins
- Add lint and format npm scripts
- Configure eslint.config.js with Svelte + TypeScript rules
- Configure .prettierrc with Svelte plugin
- Fix code to comply with lint rules:
  - Use resolve() for navigation links
  - Use SvelteMap for reactive maps
  - Use writable  instead of  +
  - Remove unused imports and variables

Note: ignoreGoto is set to true due to eslint-plugin-svelte#1327
2026-03-03 01:21:50 +01:00
609995732b feat(routines): add minimize_products option for batch suggestions 2026-03-03 00:50:49 +01:00
40f9a353bb feat(products): add shopping suggestions feature
- Add POST /api/products/suggest endpoint that analyzes skin condition
  and inventory to suggest product types (e.g., 'Salicylic Acid 2% Masque')
- Add MCP tool get_shopping_suggestions() for MCP clients
- Add 'Suggest' button to Products page in frontend
- Add /products/suggest page with suggestion cards
- Include product type, key ingredients, target concerns, why_needed,
  recommended_time, and frequency in suggestions
- Fix stock logic: sealed products now count as available inventory
- Add legend to clarify ✓ (in stock) vs ✗ (not in stock) markers
2026-03-02 22:38:08 +01:00
389ca5ffdc fix(backend): resolve ty check errors across api, mcp, and lifespan typing 2026-03-02 15:51:14 +01:00
679e4e81f4 feat(frontend): responsive design for mobile (RWD)
- Layout: mobile hamburger + drawer nav (backdrop button + sibling nav),
  desktop sidebar hidden on small screens, p-4 md:p-8 main padding
- Products: card list view on mobile, flex-wrap filters
- Lab results: card list view on mobile
- ProductForm: responsive grids (grid-cols-1 sm:grid-cols-2), skin
  profile checkboxes 2→3 cols, active ingredient row restructured
  (name+✕ in flex row, percent/strength/irritation in 3-col grid),
  section headers stack on mobile
- Skin snapshots: date+icons on one row, badges on separate row below
- Product [id] header: back link stacked above title, redundant badge removed
- Routines header: flex-col on mobile, sm:flex-row

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 13:35:25 +01:00
c85ca355df refactor(routines): streamline suggest prompt — merge inventory context, add leaving_home SPF hint
- Remove _build_inventory_context; fold pao_months into DOSTĘPNE PRODUKTY entries
- Remove "Otwarte równolegle" duplicate section from prompt
- Rename OSTATNIE RUTYNY (7 dni) → OSTATNIE RUTYNY
- Add _build_day_context and SuggestRoutineRequest.leaving_home (optional bool)
- System prompt: replace unconditional PAO rule with conditional; add SPF factor
  selection logic based on KONTEKST DNIA leaving_home value
- Frontend: leaving_home checkbox (AM only) + i18n keys pl/en

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 23:47:54 +01:00
258b8c4330 refactor(routines): use SQLAlchemy is_(False) for product filters 2026-03-01 23:23:04 +01:00
d3bd2ff30d feat(skincare): allow HEIC/HEIF uploads in skin analysis 2026-03-01 23:23:04 +01:00
f1acfa21fc feat(routines): add inventory-aware product selection rules 2026-03-01 22:15:47 +01:00
914c6087bd fix(products): work around Gemini int-enum schema rejection in parse-text
Gemini API rejects int-valued enums (StrengthLevel) in response_schema,
raising a validation error before any request is sent. Fix by introducing
AIActiveIngredient (inherits ActiveIngredient, overrides strength_level and
irritation_potential as Optional[int]) and ProductParseLLMResponse used only
as the Gemini schema. The two-step validation converts ints back to StrengthLevel
via Pydantic coercion. Adds a test covering the numeric strength level path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:00:48 +01:00
921fe3ef61 fix(frontend): pick freshest skin snapshot on dashboard 2026-03-01 21:51:07 +01:00
49c304d06f fix(routines): use system prompt for suggest and dedupe rules 2026-03-01 21:45:31 +01:00
cc657998e8 fix(llm): switch from thinking_budget to thinking_level=LOW for Gemini 3
gemini-flash-latest resolves to gemini-3-flash-preview which uses
thinking_level instead of the legacy thinking_budget (mixing both
returns HTTP 400). Use LOW to reduce thinking overhead while keeping
basic reasoning, replacing the now-incompatible thinking_budget=0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 20:15:49 +01:00
ada5f2a93b fix(llm): disable Gemini thinking to prevent MAX_TOKENS on structured output
Gemini 2.5 Flash (gemini-flash-latest) enables thinking by default.
Thinking tokens count toward max_output_tokens, leaving ~150 tokens for
actual JSON output and causing MAX_TOKENS truncation. Disable thinking
centrally in call_gemini via ThinkingConfig(thinking_budget=0).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 20:12:31 +01:00
092fd87606 fix(llm): log and handle non-STOP finish_reason from Gemini
When Gemini stops generation early (e.g. due to safety filters or
thinking-model quirks), finish_reason != STOP but no exception is raised,
causing the caller to receive truncated JSON and a confusing 502 "invalid
JSON" error. Now:
- finish_reason is extracted from candidates[0] and stored in ai_call_logs
- any non-STOP finish_reason raises HTTP 502 with a clear message
- Alembic migration adds the finish_reason column to ai_call_logs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 20:08:22 +01:00
18683925a1 fix(frontend): use /api proxy for skin photo upload in browser context
analyzeSkinPhotos was hardcoding PUBLIC_API_BASE, causing browser-side
requests to hit localhost:8000 directly instead of going through nginx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 20:01:16 +01:00
17eaa5d1bd fix(frontend): add missing priorities field to skin snapshots UI
priorities was present in the model, API, and LLM prompt but never
surfaced in the frontend — add it to create/edit forms, read view,
AI photo pre-fill, and page.server.ts actions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 19:58:38 +01:00
75ef1bca56 feat(routines): add minoxidil beard/mustache option to routine suggestions
- Add include_minoxidil_beard flag to SuggestRoutineRequest and SuggestBatchRequest
- Detect minoxidil products by scanning name, brand, INCI and actives; pass them
  to the LLM even though they are medications
- Inject CELE UŻYTKOWNIKA context block into prompts when flag is enabled
- Add _build_objectives_context() returning empty string when flag is off
- Add call_gemini() helper that centralises Gemini API calls and logs every
  request/response to a new ai_call_logs table (AICallLog model + /ai-logs router)
- Nginx: raise client_max_body_size to 16 MB for photo uploads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 19:46:07 +01:00
3aa03b412b feat(frontend): group product selector by category in routine step forms
Products in the Add/Edit step dropdowns are now grouped by category
(Cleanser → Serum → Moisturizer → …) using SelectGroup/SelectGroupHeading,
and sorted alphabetically by brand then name within each group.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 18:22:51 +01:00
78c67b6179 fix(backend): include steps in list_routines response
Batch-load all routine steps in a single query and attach them to each
routine dict, mirroring the detail endpoint pattern. Fixes "0 steps"
shown on the routines list page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 17:39:33 +01:00
d4e3040674 fix(frontend): fix step numbering and client-side API base URL
- Step numbers now use each-block index (i+1) instead of order_index+1,
  fixing display when order_index starts from 1 in existing data
- api.ts: browser-side requests use /api (nginx proxy) instead of
  PUBLIC_API_BASE (localhost:8000), fixing PATCH/client calls on remote hosts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 17:34:00 +01:00
5cb44b2c65 fix(backend): apply black/isort formatting and fix ruff noqa annotations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 17:27:07 +01:00
4b0fedde35 feat(frontend): add drag-and-drop reordering and inline editing for routine steps
- Install svelte-dnd-action v0.9.69
- Use dragHandleZone + dragHandle for per-step ⋮⋮ drag handles
- PATCH only steps whose order_index changed after a drop
- Inline edit mode (✎ button) expands step in-place: product steps show product/dose/region selects; action steps show action_type/notes
- DnD disabled while a step is being edited

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 17:21:59 +01:00
5e2536138b fix(deploy): suppress prepare script noise with --ignore-scripts
svelte-kit sync runs as a prepare hook but svelte-kit is a devDep,
not installed during prod install — add --ignore-scripts to silence it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:57:46 +01:00
91bc9e86d7 fix(deploy): install frontend prod deps on server after deploy
adapter-node bundles the SvelteKit framework but leaves package.json
`dependencies` (clsx, bits-ui, etc.) external — they must be present in
node_modules on the server at runtime.

- deploy.sh: rsync package.json + pnpm-lock.yaml, run pnpm install --prod
- DEPLOYMENT.md: add pnpm to server setup with explanation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:56:28 +01:00
3428885aaa docs: add deploy.sh and rewrite DEPLOYMENT.md for local-build workflow
- Add deploy.sh: builds frontend locally, rsyncs build/ to server,
  restarts services via passwordless sudo
- DEPLOYMENT.md: remove pnpm build from server setup (frontend is never
  built on the LXC — esbuild hangs on low-resource containers), add rsync
  to apt packages, document deploy.sh setup (SSH config, sudoers), replace
  manual update steps with ./deploy.sh invocation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:51:51 +01:00
70 changed files with 8306 additions and 3784 deletions

View file

@ -1,17 +1,21 @@
# CLAUDE.md
# AGENTS.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to AI coding agents when working with code in this repository.
## Repository structure
## Repository Structure
This is a monorepo. The backend lives in `backend/`; a frontend will be added in the future.
This is a monorepo with **backend** and **frontend** directories.
## Commit Guidelines
This repository uses Conventional Commits (e.g., `feat(api): ...`, `fix(frontend): ...`, `test(models): ...`). Always format commit messages accordingly and ensure you include the correct scope to indicate which part of the monorepo is affected.
## Commands
Run all backend commands from the `backend/` directory:
Run the backend from the `backend/` directory:
```bash
# Run scripts
# Backend
cd backend && uv run python main.py
# Linting / formatting
@ -20,11 +24,27 @@ cd backend && uv run black .
cd backend && uv run isort .
```
No test suite exists yet.
Run the frontend from the `frontend/` directory:
```bash
# Frontend
cd frontend && pnpm dev
# Type checking / linting / formatting
cd frontend && pnpm check
cd frontend && pnpm lint
cd frontend && pnpm format
```
No test suite exists yet (backend has some test files but they're not integrated into CI).
## Architecture
**innercontext** collects personal health and skincare data and exposes it via MCP to an LLM agent. Stack: Python 3.12, SQLModel (0.0.37) + SQLAlchemy, Pydantic v2, FastAPI, PostgreSQL (psycopg3).
**innercontext** collects personal health and skincare data and exposes it to an LLM agent.
**Backend Stack:** Python 3.12, SQLModel (0.0.37) + SQLAlchemy, Pydantic v2, FastAPI, PostgreSQL (psycopg3).
**Frontend Stack:** SvelteKit 5, Tailwind CSS v4, bits-ui, inlang/paraglide (i18n), svelte-dnd-action.
### Models (`backend/innercontext/models/`)
@ -35,7 +55,7 @@ No test suite exists yet.
| `routine.py` | `routines`, `routine_steps` |
| `skincare.py` | `skin_condition_snapshots` |
**`Product`** is the core model. JSON columns store `inci` (list), `actives` (list of `ActiveIngredient`), `recommended_for`, `targets`, `incompatible_with`, `synergizes_with`, `context_rules`, and `product_effect_profile`. The `to_llm_context()` method returns a token-optimised dict for MCP.
**`Product`** is the core model. JSON columns store `inci` (list), `actives` (list of `ActiveIngredient`), `recommended_for`, `targets`, `incompatible_with`, `synergizes_with`, `context_rules`, and `product_effect_profile`. The `to_llm_context()` method returns a token-optimised dict for LLM usage.
**`ProductInventory`** tracks physical packages (opened status, expiry, remaining weight). One product → many inventory entries.
@ -43,7 +63,7 @@ No test suite exists yet.
**`SkinConditionSnapshot`** is a weekly LLM-filled record (skin state, metrics 15, active concerns).
### Key conventions
### Key Conventions
- All `table=True` models use `Column(DateTime(timezone=True), onupdate=utc_now)` for `updated_at` via raw SQLAlchemy column — do not use plain `Field(default_factory=...)` for auto-update.
- List/complex fields stored as JSON use `sa_column=Column(JSON, nullable=...)` pattern (DB-agnostic; not JSONB).

View file

@ -1,11 +1,11 @@
# innercontext
Personal health and skincare data hub. Collects structured data (products, routines, lab results, medications, skin snapshots) and exposes it via a REST API, MCP, and a web UI to an LLM agent.
Personal health and skincare data hub. Collects structured data (products, routines, lab results, medications, skin snapshots) and exposes it via a REST API and a web UI to an LLM agent.
## Repository layout
```
backend/ Python backend — FastAPI REST API + MCP server + SQLModel models
backend/ Python backend — FastAPI REST API + SQLModel models
frontend/ SvelteKit web UI (Svelte 5, TypeScript, Tailwind CSS v4)
docs/ Deployment guides
nginx/ nginx config for production
@ -60,14 +60,6 @@ UI available at `http://localhost:5173`.
| `/skincare` | Weekly skin condition snapshots |
| `/health-check` | Liveness probe |
## MCP server
innercontext exposes 14 tools via [FastMCP](https://github.com/jlowin/fastmcp) at the StreamableHTTP endpoint `http://localhost:8000/mcp/mcp`.
Tools include: `get_products`, `get_product`, `get_open_inventory`, `get_recent_routines`, `get_latest_skin_snapshot`, `get_skin_history`, `get_medications`, `get_expiring_inventory`, `get_grooming_schedule`, `get_recent_lab_results`, and more.
Connect an MCP-compatible LLM agent by pointing it at `http://<host>/mcp/mcp`.
## Frontend routes
| Route | Description |
@ -102,7 +94,6 @@ uv run pytest
## Stack
- **Backend:** Python 3.12, FastAPI, Uvicorn, SQLModel 0.0.37 + SQLAlchemy, Pydantic v2, PostgreSQL (psycopg3)
- **MCP:** FastMCP 3.0.2 (StreamableHTTP transport)
- **Frontend:** SvelteKit 2, Svelte 5 (Runes), TypeScript, Tailwind CSS v4, shadcn-svelte
## Deployment

View file

@ -1,15 +1,16 @@
import os
from logging.config import fileConfig
from alembic import context
from dotenv import load_dotenv
from sqlalchemy import engine_from_config, pool
from sqlmodel import SQLModel
from alembic import context
load_dotenv()
# Import all models so their tables are registered in SQLModel.metadata
import innercontext.models # noqa: F401
import innercontext.models # noqa: F401, E402
config = context.config

View file

@ -0,0 +1,51 @@
"""add_ai_call_logs
Revision ID: a1b2c3d4e5f6
Revises: c2d626a2b36c
Create Date: 2026-03-01 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
from alembic import op
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, None] = "c2d626a2b36c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"ai_call_logs",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("endpoint", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("model", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("system_prompt", sa.Text(), nullable=True),
sa.Column("user_input", sa.Text(), nullable=True),
sa.Column("response_text", sa.Text(), nullable=True),
sa.Column("prompt_tokens", sa.Integer(), nullable=True),
sa.Column("completion_tokens", sa.Integer(), nullable=True),
sa.Column("total_tokens", sa.Integer(), nullable=True),
sa.Column("duration_ms", sa.Integer(), nullable=True),
sa.Column("success", sa.Boolean(), nullable=False),
sa.Column("error_detail", sa.Text(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_ai_call_logs_endpoint"), "ai_call_logs", ["endpoint"], unique=False
)
op.create_index(
op.f("ix_ai_call_logs_success"), "ai_call_logs", ["success"], unique=False
)
def downgrade() -> None:
op.drop_index(op.f("ix_ai_call_logs_success"), table_name="ai_call_logs")
op.drop_index(op.f("ix_ai_call_logs_endpoint"), table_name="ai_call_logs")
op.drop_table("ai_call_logs")

View file

@ -0,0 +1,29 @@
"""add_finish_reason_to_ai_call_logs
Revision ID: b2c3d4e5f6a1
Revises: a1b2c3d4e5f6
Create Date: 2026-03-01 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "b2c3d4e5f6a1"
down_revision: Union[str, None] = "a1b2c3d4e5f6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"ai_call_logs",
sa.Column("finish_reason", sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_column("ai_call_logs", "finish_reason")

View file

@ -1,19 +1,20 @@
"""initial_schema
Revision ID: c2d626a2b36c
Revises:
Revises:
Create Date: 2026-02-28 20:13:55.499494
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'c2d626a2b36c'
revision: str = "c2d626a2b36c"
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
@ -22,217 +23,456 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('grooming_schedule',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('day_of_week', sa.Integer(), nullable=False),
sa.Column('action', sa.Enum('SHAVING_RAZOR', 'SHAVING_ONEBLADE', 'DERMAROLLING', name='groomingaction'), nullable=False),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.PrimaryKeyConstraint('id')
op.create_table(
"grooming_schedule",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("day_of_week", sa.Integer(), nullable=False),
sa.Column(
"action",
sa.Enum(
"SHAVING_RAZOR",
"SHAVING_ONEBLADE",
"DERMAROLLING",
name="groomingaction",
),
nullable=False,
),
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f('ix_grooming_schedule_day_of_week'), 'grooming_schedule', ['day_of_week'], unique=False)
op.create_table('lab_results',
sa.Column('record_id', sa.Uuid(), nullable=False),
sa.Column('collected_at', sa.DateTime(), nullable=False),
sa.Column('test_code', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('test_name_original', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('test_name_loinc', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('value_num', sa.Float(), nullable=True),
sa.Column('value_text', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('value_bool', sa.Boolean(), nullable=True),
sa.Column('unit_original', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('unit_ucum', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('ref_low', sa.Float(), nullable=True),
sa.Column('ref_high', sa.Float(), nullable=True),
sa.Column('ref_text', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('flag', sa.Enum('NORMAL', 'ABNORMAL', 'POSITIVE', 'NEGATIVE', 'LOW', 'HIGH', name='resultflag'), nullable=True),
sa.Column('lab', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('source_file', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('record_id')
op.create_index(
op.f("ix_grooming_schedule_day_of_week"),
"grooming_schedule",
["day_of_week"],
unique=False,
)
op.create_index(op.f('ix_lab_results_collected_at'), 'lab_results', ['collected_at'], unique=False)
op.create_index(op.f('ix_lab_results_flag'), 'lab_results', ['flag'], unique=False)
op.create_index(op.f('ix_lab_results_lab'), 'lab_results', ['lab'], unique=False)
op.create_index(op.f('ix_lab_results_test_code'), 'lab_results', ['test_code'], unique=False)
op.create_table('medication_entries',
sa.Column('record_id', sa.Uuid(), nullable=False),
sa.Column('kind', sa.Enum('PRESCRIPTION', 'OTC', 'SUPPLEMENT', 'HERBAL', 'OTHER', name='medicationkind'), nullable=False),
sa.Column('product_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('active_substance', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('formulation', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('route', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('source_file', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('record_id')
op.create_table(
"lab_results",
sa.Column("record_id", sa.Uuid(), nullable=False),
sa.Column("collected_at", sa.DateTime(), nullable=False),
sa.Column("test_code", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column(
"test_name_original", sqlmodel.sql.sqltypes.AutoString(), nullable=True
),
sa.Column("test_name_loinc", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("value_num", sa.Float(), nullable=True),
sa.Column("value_text", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("value_bool", sa.Boolean(), nullable=True),
sa.Column("unit_original", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("unit_ucum", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("ref_low", sa.Float(), nullable=True),
sa.Column("ref_high", sa.Float(), nullable=True),
sa.Column("ref_text", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column(
"flag",
sa.Enum(
"NORMAL",
"ABNORMAL",
"POSITIVE",
"NEGATIVE",
"LOW",
"HIGH",
name="resultflag",
),
nullable=True,
),
sa.Column("lab", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("source_file", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("record_id"),
)
op.create_index(op.f('ix_medication_entries_active_substance'), 'medication_entries', ['active_substance'], unique=False)
op.create_index(op.f('ix_medication_entries_kind'), 'medication_entries', ['kind'], unique=False)
op.create_index(op.f('ix_medication_entries_product_name'), 'medication_entries', ['product_name'], unique=False)
op.create_table('products',
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('brand', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('line_name', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=True),
sa.Column('sku', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True),
sa.Column('url', sqlmodel.sql.sqltypes.AutoString(length=512), nullable=True),
sa.Column('barcode', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True),
sa.Column('category', sa.Enum('CLEANSER', 'TONER', 'ESSENCE', 'SERUM', 'MOISTURIZER', 'SPF', 'MASK', 'EXFOLIANT', 'HAIR_TREATMENT', 'TOOL', 'SPOT_TREATMENT', 'OIL', name='productcategory'), nullable=False),
sa.Column('recommended_time', sa.Enum('AM', 'PM', 'BOTH', name='daytime'), nullable=False),
sa.Column('texture', sa.Enum('WATERY', 'GEL', 'EMULSION', 'CREAM', 'OIL', 'BALM', 'FOAM', 'FLUID', name='texturetype'), nullable=True),
sa.Column('absorption_speed', sa.Enum('VERY_FAST', 'FAST', 'MODERATE', 'SLOW', 'VERY_SLOW', name='absorptionspeed'), nullable=True),
sa.Column('leave_on', sa.Boolean(), nullable=False),
sa.Column('size_ml', sa.Float(), nullable=True),
sa.Column('full_weight_g', sa.Float(), nullable=True),
sa.Column('empty_weight_g', sa.Float(), nullable=True),
sa.Column('pao_months', sa.Integer(), nullable=True),
sa.Column('price_tier', sa.Enum('BUDGET', 'MID', 'PREMIUM', 'LUXURY', name='pricetier'), nullable=True),
sa.Column('inci', sa.JSON(), nullable=False),
sa.Column('actives', sa.JSON(), nullable=True),
sa.Column('recommended_for', sa.JSON(), nullable=False),
sa.Column('targets', sa.JSON(), nullable=False),
sa.Column('contraindications', sa.JSON(), nullable=False),
sa.Column('usage_notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('fragrance_free', sa.Boolean(), nullable=True),
sa.Column('essential_oils_free', sa.Boolean(), nullable=True),
sa.Column('alcohol_denat_free', sa.Boolean(), nullable=True),
sa.Column('pregnancy_safe', sa.Boolean(), nullable=True),
sa.Column('product_effect_profile', sa.JSON(), nullable=False),
sa.Column('ph_min', sa.Float(), nullable=True),
sa.Column('ph_max', sa.Float(), nullable=True),
sa.Column('incompatible_with', sa.JSON(), nullable=True),
sa.Column('synergizes_with', sa.JSON(), nullable=True),
sa.Column('context_rules', sa.JSON(), nullable=True),
sa.Column('min_interval_hours', sa.Integer(), nullable=True),
sa.Column('max_frequency_per_week', sa.Integer(), nullable=True),
sa.Column('is_medication', sa.Boolean(), nullable=False),
sa.Column('is_tool', sa.Boolean(), nullable=False),
sa.Column('needle_length_mm', sa.Float(), nullable=True),
sa.Column('personal_tolerance_notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('personal_repurchase_intent', sa.Boolean(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
op.create_index(
op.f("ix_lab_results_collected_at"),
"lab_results",
["collected_at"],
unique=False,
)
op.create_index(op.f('ix_products_price_tier'), 'products', ['price_tier'], unique=False)
op.create_table('routines',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('routine_date', sa.Date(), nullable=False),
sa.Column('part_of_day', sa.Enum('AM', 'PM', name='partofday'), nullable=False),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('routine_date', 'part_of_day', name='uq_routine_date_part_of_day')
op.create_index(op.f("ix_lab_results_flag"), "lab_results", ["flag"], unique=False)
op.create_index(op.f("ix_lab_results_lab"), "lab_results", ["lab"], unique=False)
op.create_index(
op.f("ix_lab_results_test_code"), "lab_results", ["test_code"], unique=False
)
op.create_index(op.f('ix_routines_part_of_day'), 'routines', ['part_of_day'], unique=False)
op.create_index(op.f('ix_routines_routine_date'), 'routines', ['routine_date'], unique=False)
op.create_table('skin_condition_snapshots',
sa.Column('snapshot_date', sa.Date(), nullable=False),
sa.Column('overall_state', sa.Enum('EXCELLENT', 'GOOD', 'FAIR', 'POOR', name='overallskinstate'), nullable=True),
sa.Column('skin_type', sa.Enum('DRY', 'OILY', 'COMBINATION', 'SENSITIVE', 'NORMAL', 'ACNE_PRONE', name='skintype'), nullable=True),
sa.Column('texture', sa.Enum('SMOOTH', 'ROUGH', 'FLAKY', 'BUMPY', name='skintexture'), nullable=True),
sa.Column('hydration_level', sa.Integer(), nullable=True),
sa.Column('sebum_tzone', sa.Integer(), nullable=True),
sa.Column('sebum_cheeks', sa.Integer(), nullable=True),
sa.Column('sensitivity_level', sa.Integer(), nullable=True),
sa.Column('barrier_state', sa.Enum('INTACT', 'MILDLY_COMPROMISED', 'COMPROMISED', name='barrierstate'), nullable=True),
sa.Column('active_concerns', sa.JSON(), nullable=False),
sa.Column('risks', sa.JSON(), nullable=False),
sa.Column('priorities', sa.JSON(), nullable=False),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('snapshot_date', name='uq_skin_snapshot_date')
op.create_table(
"medication_entries",
sa.Column("record_id", sa.Uuid(), nullable=False),
sa.Column(
"kind",
sa.Enum(
"PRESCRIPTION",
"OTC",
"SUPPLEMENT",
"HERBAL",
"OTHER",
name="medicationkind",
),
nullable=False,
),
sa.Column("product_name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column(
"active_substance", sqlmodel.sql.sqltypes.AutoString(), nullable=True
),
sa.Column("formulation", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("route", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("source_file", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("record_id"),
)
op.create_index(op.f('ix_skin_condition_snapshots_snapshot_date'), 'skin_condition_snapshots', ['snapshot_date'], unique=False)
op.create_table('medication_usages',
sa.Column('record_id', sa.Uuid(), nullable=False),
sa.Column('medication_record_id', sa.Uuid(), nullable=False),
sa.Column('dose_value', sa.Float(), nullable=True),
sa.Column('dose_unit', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('frequency', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('schedule_text', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('as_needed', sa.Boolean(), nullable=False),
sa.Column('valid_from', sa.DateTime(), nullable=False),
sa.Column('valid_to', sa.DateTime(), nullable=True),
sa.Column('source_file', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['medication_record_id'], ['medication_entries.record_id'], ),
sa.PrimaryKeyConstraint('record_id')
op.create_index(
op.f("ix_medication_entries_active_substance"),
"medication_entries",
["active_substance"],
unique=False,
)
op.create_index(op.f('ix_medication_usages_as_needed'), 'medication_usages', ['as_needed'], unique=False)
op.create_index(op.f('ix_medication_usages_medication_record_id'), 'medication_usages', ['medication_record_id'], unique=False)
op.create_index(op.f('ix_medication_usages_valid_from'), 'medication_usages', ['valid_from'], unique=False)
op.create_index(op.f('ix_medication_usages_valid_to'), 'medication_usages', ['valid_to'], unique=False)
op.create_table('product_inventory',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('product_id', sa.Uuid(), nullable=False),
sa.Column('is_opened', sa.Boolean(), nullable=False),
sa.Column('opened_at', sa.Date(), nullable=True),
sa.Column('finished_at', sa.Date(), nullable=True),
sa.Column('expiry_date', sa.Date(), nullable=True),
sa.Column('current_weight_g', sa.Float(), nullable=True),
sa.Column('last_weighed_at', sa.Date(), nullable=True),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.PrimaryKeyConstraint('id')
op.create_index(
op.f("ix_medication_entries_kind"), "medication_entries", ["kind"], unique=False
)
op.create_index(op.f('ix_product_inventory_product_id'), 'product_inventory', ['product_id'], unique=False)
op.create_table('routine_steps',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('routine_id', sa.Uuid(), nullable=False),
sa.Column('product_id', sa.Uuid(), nullable=True),
sa.Column('order_index', sa.Integer(), nullable=False),
sa.Column('action_type', sa.Enum('SHAVING_RAZOR', 'SHAVING_ONEBLADE', 'DERMAROLLING', name='groomingaction'), nullable=True),
sa.Column('action_notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('dose', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('region', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.ForeignKeyConstraint(['routine_id'], ['routines.id'], ),
sa.PrimaryKeyConstraint('id')
op.create_index(
op.f("ix_medication_entries_product_name"),
"medication_entries",
["product_name"],
unique=False,
)
op.create_table(
"products",
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("brand", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column(
"line_name", sqlmodel.sql.sqltypes.AutoString(length=128), nullable=True
),
sa.Column("sku", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True),
sa.Column("url", sqlmodel.sql.sqltypes.AutoString(length=512), nullable=True),
sa.Column(
"barcode", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True
),
sa.Column(
"category",
sa.Enum(
"CLEANSER",
"TONER",
"ESSENCE",
"SERUM",
"MOISTURIZER",
"SPF",
"MASK",
"EXFOLIANT",
"HAIR_TREATMENT",
"TOOL",
"SPOT_TREATMENT",
"OIL",
name="productcategory",
),
nullable=False,
),
sa.Column(
"recommended_time",
sa.Enum("AM", "PM", "BOTH", name="daytime"),
nullable=False,
),
sa.Column(
"texture",
sa.Enum(
"WATERY",
"GEL",
"EMULSION",
"CREAM",
"OIL",
"BALM",
"FOAM",
"FLUID",
name="texturetype",
),
nullable=True,
),
sa.Column(
"absorption_speed",
sa.Enum(
"VERY_FAST",
"FAST",
"MODERATE",
"SLOW",
"VERY_SLOW",
name="absorptionspeed",
),
nullable=True,
),
sa.Column("leave_on", sa.Boolean(), nullable=False),
sa.Column("size_ml", sa.Float(), nullable=True),
sa.Column("full_weight_g", sa.Float(), nullable=True),
sa.Column("empty_weight_g", sa.Float(), nullable=True),
sa.Column("pao_months", sa.Integer(), nullable=True),
sa.Column(
"price_tier",
sa.Enum("BUDGET", "MID", "PREMIUM", "LUXURY", name="pricetier"),
nullable=True,
),
sa.Column("inci", sa.JSON(), nullable=False),
sa.Column("actives", sa.JSON(), nullable=True),
sa.Column("recommended_for", sa.JSON(), nullable=False),
sa.Column("targets", sa.JSON(), nullable=False),
sa.Column("contraindications", sa.JSON(), nullable=False),
sa.Column("usage_notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("fragrance_free", sa.Boolean(), nullable=True),
sa.Column("essential_oils_free", sa.Boolean(), nullable=True),
sa.Column("alcohol_denat_free", sa.Boolean(), nullable=True),
sa.Column("pregnancy_safe", sa.Boolean(), nullable=True),
sa.Column("product_effect_profile", sa.JSON(), nullable=False),
sa.Column("ph_min", sa.Float(), nullable=True),
sa.Column("ph_max", sa.Float(), nullable=True),
sa.Column("incompatible_with", sa.JSON(), nullable=True),
sa.Column("synergizes_with", sa.JSON(), nullable=True),
sa.Column("context_rules", sa.JSON(), nullable=True),
sa.Column("min_interval_hours", sa.Integer(), nullable=True),
sa.Column("max_frequency_per_week", sa.Integer(), nullable=True),
sa.Column("is_medication", sa.Boolean(), nullable=False),
sa.Column("is_tool", sa.Boolean(), nullable=False),
sa.Column("needle_length_mm", sa.Float(), nullable=True),
sa.Column(
"personal_tolerance_notes",
sqlmodel.sql.sqltypes.AutoString(),
nullable=True,
),
sa.Column("personal_repurchase_intent", sa.Boolean(), nullable=True),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_products_price_tier"), "products", ["price_tier"], unique=False
)
op.create_table(
"routines",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("routine_date", sa.Date(), nullable=False),
sa.Column("part_of_day", sa.Enum("AM", "PM", name="partofday"), nullable=False),
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"routine_date", "part_of_day", name="uq_routine_date_part_of_day"
),
)
op.create_index(
op.f("ix_routines_part_of_day"), "routines", ["part_of_day"], unique=False
)
op.create_index(
op.f("ix_routines_routine_date"), "routines", ["routine_date"], unique=False
)
op.create_table(
"skin_condition_snapshots",
sa.Column("snapshot_date", sa.Date(), nullable=False),
sa.Column(
"overall_state",
sa.Enum("EXCELLENT", "GOOD", "FAIR", "POOR", name="overallskinstate"),
nullable=True,
),
sa.Column(
"skin_type",
sa.Enum(
"DRY",
"OILY",
"COMBINATION",
"SENSITIVE",
"NORMAL",
"ACNE_PRONE",
name="skintype",
),
nullable=True,
),
sa.Column(
"texture",
sa.Enum("SMOOTH", "ROUGH", "FLAKY", "BUMPY", name="skintexture"),
nullable=True,
),
sa.Column("hydration_level", sa.Integer(), nullable=True),
sa.Column("sebum_tzone", sa.Integer(), nullable=True),
sa.Column("sebum_cheeks", sa.Integer(), nullable=True),
sa.Column("sensitivity_level", sa.Integer(), nullable=True),
sa.Column(
"barrier_state",
sa.Enum("INTACT", "MILDLY_COMPROMISED", "COMPROMISED", name="barrierstate"),
nullable=True,
),
sa.Column("active_concerns", sa.JSON(), nullable=False),
sa.Column("risks", sa.JSON(), nullable=False),
sa.Column("priorities", sa.JSON(), nullable=False),
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("snapshot_date", name="uq_skin_snapshot_date"),
)
op.create_index(
op.f("ix_skin_condition_snapshots_snapshot_date"),
"skin_condition_snapshots",
["snapshot_date"],
unique=False,
)
op.create_table(
"medication_usages",
sa.Column("record_id", sa.Uuid(), nullable=False),
sa.Column("medication_record_id", sa.Uuid(), nullable=False),
sa.Column("dose_value", sa.Float(), nullable=True),
sa.Column("dose_unit", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("frequency", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("schedule_text", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("as_needed", sa.Boolean(), nullable=False),
sa.Column("valid_from", sa.DateTime(), nullable=False),
sa.Column("valid_to", sa.DateTime(), nullable=True),
sa.Column("source_file", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["medication_record_id"],
["medication_entries.record_id"],
),
sa.PrimaryKeyConstraint("record_id"),
)
op.create_index(
op.f("ix_medication_usages_as_needed"),
"medication_usages",
["as_needed"],
unique=False,
)
op.create_index(
op.f("ix_medication_usages_medication_record_id"),
"medication_usages",
["medication_record_id"],
unique=False,
)
op.create_index(
op.f("ix_medication_usages_valid_from"),
"medication_usages",
["valid_from"],
unique=False,
)
op.create_index(
op.f("ix_medication_usages_valid_to"),
"medication_usages",
["valid_to"],
unique=False,
)
op.create_table(
"product_inventory",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("product_id", sa.Uuid(), nullable=False),
sa.Column("is_opened", sa.Boolean(), nullable=False),
sa.Column("opened_at", sa.Date(), nullable=True),
sa.Column("finished_at", sa.Date(), nullable=True),
sa.Column("expiry_date", sa.Date(), nullable=True),
sa.Column("current_weight_g", sa.Float(), nullable=True),
sa.Column("last_weighed_at", sa.Date(), nullable=True),
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["product_id"],
["products.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_product_inventory_product_id"),
"product_inventory",
["product_id"],
unique=False,
)
op.create_table(
"routine_steps",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("routine_id", sa.Uuid(), nullable=False),
sa.Column("product_id", sa.Uuid(), nullable=True),
sa.Column("order_index", sa.Integer(), nullable=False),
sa.Column(
"action_type",
sa.Enum(
"SHAVING_RAZOR",
"SHAVING_ONEBLADE",
"DERMAROLLING",
name="groomingaction",
),
nullable=True,
),
sa.Column("action_notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("dose", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("region", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(
["product_id"],
["products.id"],
),
sa.ForeignKeyConstraint(
["routine_id"],
["routines.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_routine_steps_product_id"),
"routine_steps",
["product_id"],
unique=False,
)
op.create_index(
op.f("ix_routine_steps_routine_id"),
"routine_steps",
["routine_id"],
unique=False,
)
op.create_index(op.f('ix_routine_steps_product_id'), 'routine_steps', ['product_id'], unique=False)
op.create_index(op.f('ix_routine_steps_routine_id'), 'routine_steps', ['routine_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_routine_steps_routine_id'), table_name='routine_steps')
op.drop_index(op.f('ix_routine_steps_product_id'), table_name='routine_steps')
op.drop_table('routine_steps')
op.drop_index(op.f('ix_product_inventory_product_id'), table_name='product_inventory')
op.drop_table('product_inventory')
op.drop_index(op.f('ix_medication_usages_valid_to'), table_name='medication_usages')
op.drop_index(op.f('ix_medication_usages_valid_from'), table_name='medication_usages')
op.drop_index(op.f('ix_medication_usages_medication_record_id'), table_name='medication_usages')
op.drop_index(op.f('ix_medication_usages_as_needed'), table_name='medication_usages')
op.drop_table('medication_usages')
op.drop_index(op.f('ix_skin_condition_snapshots_snapshot_date'), table_name='skin_condition_snapshots')
op.drop_table('skin_condition_snapshots')
op.drop_index(op.f('ix_routines_routine_date'), table_name='routines')
op.drop_index(op.f('ix_routines_part_of_day'), table_name='routines')
op.drop_table('routines')
op.drop_index(op.f('ix_products_price_tier'), table_name='products')
op.drop_table('products')
op.drop_index(op.f('ix_medication_entries_product_name'), table_name='medication_entries')
op.drop_index(op.f('ix_medication_entries_kind'), table_name='medication_entries')
op.drop_index(op.f('ix_medication_entries_active_substance'), table_name='medication_entries')
op.drop_table('medication_entries')
op.drop_index(op.f('ix_lab_results_test_code'), table_name='lab_results')
op.drop_index(op.f('ix_lab_results_lab'), table_name='lab_results')
op.drop_index(op.f('ix_lab_results_flag'), table_name='lab_results')
op.drop_index(op.f('ix_lab_results_collected_at'), table_name='lab_results')
op.drop_table('lab_results')
op.drop_index(op.f('ix_grooming_schedule_day_of_week'), table_name='grooming_schedule')
op.drop_table('grooming_schedule')
op.drop_index(op.f("ix_routine_steps_routine_id"), table_name="routine_steps")
op.drop_index(op.f("ix_routine_steps_product_id"), table_name="routine_steps")
op.drop_table("routine_steps")
op.drop_index(
op.f("ix_product_inventory_product_id"), table_name="product_inventory"
)
op.drop_table("product_inventory")
op.drop_index(op.f("ix_medication_usages_valid_to"), table_name="medication_usages")
op.drop_index(
op.f("ix_medication_usages_valid_from"), table_name="medication_usages"
)
op.drop_index(
op.f("ix_medication_usages_medication_record_id"),
table_name="medication_usages",
)
op.drop_index(
op.f("ix_medication_usages_as_needed"), table_name="medication_usages"
)
op.drop_table("medication_usages")
op.drop_index(
op.f("ix_skin_condition_snapshots_snapshot_date"),
table_name="skin_condition_snapshots",
)
op.drop_table("skin_condition_snapshots")
op.drop_index(op.f("ix_routines_routine_date"), table_name="routines")
op.drop_index(op.f("ix_routines_part_of_day"), table_name="routines")
op.drop_table("routines")
op.drop_index(op.f("ix_products_price_tier"), table_name="products")
op.drop_table("products")
op.drop_index(
op.f("ix_medication_entries_product_name"), table_name="medication_entries"
)
op.drop_index(op.f("ix_medication_entries_kind"), table_name="medication_entries")
op.drop_index(
op.f("ix_medication_entries_active_substance"), table_name="medication_entries"
)
op.drop_table("medication_entries")
op.drop_index(op.f("ix_lab_results_test_code"), table_name="lab_results")
op.drop_index(op.f("ix_lab_results_lab"), table_name="lab_results")
op.drop_index(op.f("ix_lab_results_flag"), table_name="lab_results")
op.drop_index(op.f("ix_lab_results_collected_at"), table_name="lab_results")
op.drop_table("lab_results")
op.drop_index(
op.f("ix_grooming_schedule_day_of_week"), table_name="grooming_schedule"
)
op.drop_table("grooming_schedule")
# ### end Alembic commands ###

View file

@ -0,0 +1,29 @@
"""add_tool_trace_to_ai_call_logs
Revision ID: d3e4f5a6b7c8
Revises: b2c3d4e5f6a1
Create Date: 2026-03-04 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "d3e4f5a6b7c8"
down_revision: Union[str, None] = "b2c3d4e5f6a1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"ai_call_logs",
sa.Column("tool_trace", sa.JSON(), nullable=True),
)
def downgrade() -> None:
op.drop_column("ai_call_logs", "tool_trace")

View file

@ -0,0 +1,83 @@
import json
from typing import Any, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, SQLModel, col, select
from db import get_session
from innercontext.models.ai_log import AICallLog
router = APIRouter()
def _normalize_tool_trace(value: object) -> dict[str, Any] | None:
if value is None:
return None
if isinstance(value, dict):
return {str(k): v for k, v in value.items()}
if isinstance(value, str):
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return None
if isinstance(parsed, dict):
return {str(k): v for k, v in parsed.items()}
return None
return None
class AICallLogPublic(SQLModel):
"""List-friendly view: omits large text fields."""
id: UUID
created_at: object
endpoint: str
model: str
prompt_tokens: Optional[int] = None
completion_tokens: Optional[int] = None
total_tokens: Optional[int] = None
duration_ms: Optional[int] = None
tool_trace: Optional[dict[str, Any]] = None
success: bool
error_detail: Optional[str] = None
@router.get("", response_model=list[AICallLogPublic])
def list_ai_logs(
endpoint: Optional[str] = None,
success: Optional[bool] = None,
limit: int = 50,
session: Session = Depends(get_session),
):
stmt = select(AICallLog).order_by(col(AICallLog.created_at).desc()).limit(limit)
if endpoint is not None:
stmt = stmt.where(AICallLog.endpoint == endpoint)
if success is not None:
stmt = stmt.where(AICallLog.success == success)
logs = session.exec(stmt).all()
return [
AICallLogPublic(
id=log.id,
created_at=log.created_at,
endpoint=log.endpoint,
model=log.model,
prompt_tokens=log.prompt_tokens,
completion_tokens=log.completion_tokens,
total_tokens=log.total_tokens,
duration_ms=log.duration_ms,
tool_trace=_normalize_tool_trace(getattr(log, "tool_trace", None)),
success=log.success,
error_detail=log.error_detail,
)
for log in logs
]
@router.get("/{log_id}", response_model=AICallLog)
def get_ai_log(log_id: UUID, session: Session = Depends(get_session)):
log = session.get(AICallLog, log_id)
if log is None:
raise HTTPException(status_code=404, detail="Log not found")
log.tool_trace = _normalize_tool_trace(getattr(log, "tool_trace", None))
return log

View file

@ -5,12 +5,18 @@ from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Query
from google.genai import types as genai_types
from pydantic import BaseModel as PydanticBase
from pydantic import ValidationError
from sqlmodel import Session, SQLModel, select
from sqlmodel import Session, SQLModel, col, select
from db import get_session
from innercontext.api.utils import get_or_404
from innercontext.llm import get_gemini_client
from innercontext.llm import (
call_gemini,
call_gemini_with_function_tools,
get_creative_config,
get_extraction_config,
)
from innercontext.models import (
Product,
ProductBase,
@ -19,6 +25,7 @@ from innercontext.models import (
ProductPublic,
ProductWithInventory,
SkinConcern,
SkinConditionSnapshot,
)
from innercontext.models.enums import (
AbsorptionSpeed,
@ -143,6 +150,19 @@ class ProductParseResponse(SQLModel):
needle_length_mm: Optional[float] = None
class AIActiveIngredient(ActiveIngredient):
# Gemini API rejects int-enum values in response_schema; override with plain int.
strength_level: Optional[int] = None # type: ignore[assignment]
irritation_potential: Optional[int] = None # type: ignore[assignment]
class ProductParseLLMResponse(ProductParseResponse):
# Gemini response schema currently requires enum values to be strings.
# Strength fields are numeric in our domain (1-3), so keep them as ints here
# and convert via ProductParseResponse validation afterward.
actives: Optional[list[AIActiveIngredient]] = None # type: ignore[assignment]
class InventoryCreate(SQLModel):
is_opened: bool = False
opened_at: Optional[date] = None
@ -163,6 +183,41 @@ class InventoryUpdate(SQLModel):
notes: Optional[str] = None
# ---------------------------------------------------------------------------
# Shopping suggestion schemas
# ---------------------------------------------------------------------------
class ProductSuggestion(PydanticBase):
category: str
product_type: str
key_ingredients: list[str]
target_concerns: list[str]
why_needed: str
recommended_time: str
frequency: str
class ShoppingSuggestionResponse(PydanticBase):
suggestions: list[ProductSuggestion]
reasoning: str
class _ProductSuggestionOut(PydanticBase):
category: str
product_type: str
key_ingredients: list[str]
target_concerns: list[str]
why_needed: str
recommended_time: str
frequency: str
class _ShoppingSuggestionsOut(PydanticBase):
suggestions: list[_ProductSuggestionOut]
reasoning: str
# ---------------------------------------------------------------------------
# Product routes
# ---------------------------------------------------------------------------
@ -205,7 +260,9 @@ def list_products(
product_ids = [p.id for p in products]
inventory_rows = (
session.exec(
select(ProductInventory).where(ProductInventory.product_id.in_(product_ids))
select(ProductInventory).where(
col(ProductInventory.product_id).in_(product_ids)
)
).all()
if product_ids
else []
@ -367,17 +424,15 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine):
@router.post("/parse-text", response_model=ProductParseResponse)
def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
client, model = get_gemini_client()
response = client.models.generate_content(
model=model,
response = call_gemini(
endpoint="products/parse-text",
contents=f"Extract product data from this text:\n\n{data.text}",
config=genai_types.GenerateContentConfig(
config=get_extraction_config(
system_instruction=_product_parse_system_prompt(),
response_mime_type="application/json",
response_schema=ProductParseResponse,
response_schema=ProductParseLLMResponse,
max_output_tokens=16384,
temperature=0.0,
),
user_input=data.text,
)
raw = response.text
if not raw:
@ -387,7 +442,8 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
except json.JSONDecodeError as e:
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
try:
return ProductParseResponse.model_validate(parsed)
llm_parsed = ProductParseLLMResponse.model_validate(parsed)
return ProductParseResponse.model_validate(llm_parsed.model_dump())
except ValidationError as e:
raise HTTPException(status_code=422, detail=e.errors())
@ -453,3 +509,425 @@ def create_product_inventory(
session.commit()
session.refresh(entry)
return entry
# ---------------------------------------------------------------------------
# Shopping suggestion
# ---------------------------------------------------------------------------
def _ev(v: object) -> str:
if v is None:
return ""
value = getattr(v, "value", None)
if isinstance(value, str):
return value
return str(v)
def _build_shopping_context(session: Session) -> str:
snapshot = session.exec(
select(SkinConditionSnapshot).order_by(
col(SkinConditionSnapshot.snapshot_date).desc()
)
).first()
skin_lines = ["STAN SKÓRY:"]
if snapshot:
skin_lines.append(f" Data: {snapshot.snapshot_date}")
skin_lines.append(f" Ogólny stan: {_ev(snapshot.overall_state)}")
skin_lines.append(f" Typ skóry: {_ev(snapshot.skin_type)}")
skin_lines.append(f" Nawilżenie: {snapshot.hydration_level}/5")
skin_lines.append(f" Wrażliwość: {snapshot.sensitivity_level}/5")
skin_lines.append(f" Bariera: {_ev(snapshot.barrier_state)}")
concerns = ", ".join(_ev(c) for c in (snapshot.active_concerns or []))
skin_lines.append(f" Aktywne problemy: {concerns or 'brak'}")
if snapshot.priorities:
skin_lines.append(f" Priorytety: {', '.join(snapshot.priorities)}")
else:
skin_lines.append(" (brak danych)")
products = _get_shopping_products(session)
product_ids = [p.id for p in products]
inventory_rows = (
session.exec(
select(ProductInventory).where(
col(ProductInventory.product_id).in_(product_ids)
)
).all()
if product_ids
else []
)
inv_by_product: dict = {}
for inv in inventory_rows:
inv_by_product.setdefault(inv.product_id, []).append(inv)
products_lines = ["POSIADANE PRODUKTY:"]
products_lines.append(
" Legenda: [✓] = produkt dostępny (w magazynie), [✗] = brak w magazynie"
)
for p in products:
active_inv = [i for i in inv_by_product.get(p.id, []) if i.finished_at is None]
has_stock = len(active_inv) > 0 # any unfinished inventory = in stock
stock = "" if has_stock else ""
actives = _extract_active_names(p)
actives_str = f", actives: {actives}" if actives else ""
ep = p.product_effect_profile
if isinstance(ep, dict):
effects = {k.replace("_strength", ""): v for k, v in ep.items() if v >= 3}
else:
effects = {
k.replace("_strength", ""): v
for k, v in ep.model_dump().items()
if v >= 3
}
effects_str = f", effects: {effects}" if effects else ""
targets = [_ev(t) for t in (p.targets or [])]
products_lines.append(
f" [{stock}] id={p.id} {p.name} ({p.brand or ''}) - {_ev(p.category)}, "
f"targets: {targets}{actives_str}{effects_str}"
)
return "\n".join(skin_lines) + "\n\n" + "\n".join(products_lines)
def _get_shopping_products(session: Session) -> list[Product]:
stmt = select(Product).where(col(Product.is_tool).is_(False))
products = session.exec(stmt).all()
return [p for p in products if not p.is_medication]
def _extract_active_names(product: Product) -> list[str]:
names: list[str] = []
for active in product.actives or []:
if isinstance(active, dict):
name = str(active.get("name") or "").strip()
else:
name = str(getattr(active, "name", "") or "").strip()
if not name:
continue
if name in names:
continue
names.append(name)
if len(names) >= 12:
break
return names
def _extract_requested_product_ids(
args: dict[str, object], max_ids: int = 8
) -> list[str]:
raw_ids = args.get("product_ids")
if not isinstance(raw_ids, list):
return []
requested_ids: list[str] = []
seen: set[str] = set()
for raw_id in raw_ids:
if not isinstance(raw_id, str):
continue
if raw_id in seen:
continue
seen.add(raw_id)
requested_ids.append(raw_id)
if len(requested_ids) >= max_ids:
break
return requested_ids
def _build_product_details_tool_handler(products: list[Product], mapper):
available_by_id = {str(p.id): p for p in products}
def _handler(args: dict[str, object]) -> dict[str, object]:
requested_ids = _extract_requested_product_ids(args)
products_payload = []
for pid in requested_ids:
product = available_by_id.get(pid)
if product is None:
continue
products_payload.append(mapper(product, pid))
return {"products": products_payload}
return _handler
def _build_inci_tool_handler(products: list[Product]):
def _mapper(product: Product, pid: str) -> dict[str, object]:
inci = product.inci or []
compact_inci = [str(i)[:120] for i in inci[:128]]
return {"id": pid, "name": product.name, "inci": compact_inci}
return _build_product_details_tool_handler(products, mapper=_mapper)
def _build_actives_tool_handler(products: list[Product]):
def _mapper(product: Product, pid: str) -> dict[str, object]:
payload = []
for active in product.actives or []:
if isinstance(active, dict):
name = str(active.get("name") or "").strip()
if not name:
continue
item = {"name": name}
percent = active.get("percent")
if percent is not None:
item["percent"] = percent
functions = active.get("functions")
if isinstance(functions, list):
item["functions"] = [str(f) for f in functions[:4]]
strength_level = active.get("strength_level")
if strength_level is not None:
item["strength_level"] = str(strength_level)
payload.append(item)
continue
name = str(getattr(active, "name", "") or "").strip()
if not name:
continue
item = {"name": name}
percent = getattr(active, "percent", None)
if percent is not None:
item["percent"] = percent
functions = getattr(active, "functions", None)
if isinstance(functions, list):
item["functions"] = [_ev(f) for f in functions[:4]]
strength_level = getattr(active, "strength_level", None)
if strength_level is not None:
item["strength_level"] = _ev(strength_level)
payload.append(item)
return {"id": pid, "name": product.name, "actives": payload[:24]}
return _build_product_details_tool_handler(products, mapper=_mapper)
def _build_usage_notes_tool_handler(products: list[Product]):
def _mapper(product: Product, pid: str) -> dict[str, object]:
notes = " ".join(str(product.usage_notes or "").split())
if len(notes) > 500:
notes = notes[:497] + "..."
return {"id": pid, "name": product.name, "usage_notes": notes}
return _build_product_details_tool_handler(products, mapper=_mapper)
def _build_safety_rules_tool_handler(products: list[Product]):
def _mapper(product: Product, pid: str) -> dict[str, object]:
ctx = product.to_llm_context()
return {
"id": pid,
"name": product.name,
"incompatible_with": (ctx.get("incompatible_with") or [])[:24],
"contraindications": (ctx.get("contraindications") or [])[:24],
"context_rules": ctx.get("context_rules") or {},
"safety": ctx.get("safety") or {},
"min_interval_hours": ctx.get("min_interval_hours"),
"max_frequency_per_week": ctx.get("max_frequency_per_week"),
}
return _build_product_details_tool_handler(products, mapper=_mapper)
_INCI_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
name="get_product_inci",
description=(
"Return exact INCI ingredient lists for selected product UUIDs from "
"POSIADANE PRODUKTY."
),
parameters=genai_types.Schema(
type=genai_types.Type.OBJECT,
properties={
"product_ids": genai_types.Schema(
type=genai_types.Type.ARRAY,
items=genai_types.Schema(type=genai_types.Type.STRING),
description="Product UUIDs from POSIADANE PRODUKTY.",
)
},
required=["product_ids"],
),
)
_SAFETY_RULES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
name="get_product_safety_rules",
description=(
"Return safety and compatibility rules for selected product UUIDs, "
"including incompatible_with, contraindications, context_rules and safety flags."
),
parameters=genai_types.Schema(
type=genai_types.Type.OBJECT,
properties={
"product_ids": genai_types.Schema(
type=genai_types.Type.ARRAY,
items=genai_types.Schema(type=genai_types.Type.STRING),
description="Product UUIDs from POSIADANE PRODUKTY.",
)
},
required=["product_ids"],
),
)
_ACTIVES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
name="get_product_actives",
description=(
"Return detailed active ingredients for selected product UUIDs, "
"including concentration and functions when available."
),
parameters=genai_types.Schema(
type=genai_types.Type.OBJECT,
properties={
"product_ids": genai_types.Schema(
type=genai_types.Type.ARRAY,
items=genai_types.Schema(type=genai_types.Type.STRING),
description="Product UUIDs from POSIADANE PRODUKTY.",
)
},
required=["product_ids"],
),
)
_USAGE_NOTES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
name="get_product_usage_notes",
description=(
"Return compact usage notes for selected product UUIDs "
"(timing, application method and cautions)."
),
parameters=genai_types.Schema(
type=genai_types.Type.OBJECT,
properties={
"product_ids": genai_types.Schema(
type=genai_types.Type.ARRAY,
items=genai_types.Schema(type=genai_types.Type.STRING),
description="Product UUIDs from POSIADANE PRODUKTY.",
)
},
required=["product_ids"],
),
)
_SHOPPING_SYSTEM_PROMPT = """Jesteś asystentem zakupowym w dziedzinie pielęgnacji skóry.
Twoim zadaniem jest przeanalizować stan skóry użytkownika oraz produkty, które już posiada,
a następnie zasugerować TYPY produktów (bez marek), które mogłyby uzupełnić ich rutynę.
LEGENDA:
- [] = produkt dostępny w magazynie (nawet jeśli jest zapieczętowany)
- [] = produkt niedostępny (brak w magazynie, wszystkie opakowania zużyte)
ZASADY:
0. Sugeruj tylko wtedy, gdy jest realna potrzeba - nie zwracaj stałej liczby produktów
1. Sugeruj TYLKO typy produktów, NIGDY konkretne marki (np. "Salicylic Acid 2% Masque", nie "La Roche-Posay")
2. Produkty oznaczone [] to te, których NIE MA w magazynie - możesz je zasugerować
3. Produkty oznaczone [] już dostępne - nie sugeruj ich ponownie
4. Bierz pod uwagę aktywne problemy skóry (acne, hyperpigmentacja, aging, etc.)
5. Sugeruj realistyczną częstotliwość użycia (dzienna, 2-3x tygodniowo, etc.)
6. Zachowaj kolejność warstw: cleanse toner serum moisturizer SPF
7. Jeśli użytkownik ma uszkodzoną barierę, unikaj silnych eksfoliantów i retinoidów
8. Zwracaj uwagę na ewentualne konflikty polecanych składników z tymi, które użytkownik już posiada (np. nie polecaj peptydów miedziowych jeśli użytkownik nadużywa kwasów)
9. Odpowiadaj w języku polskim
Format odpowiedzi - zwróć wyłącznie JSON zgodny z podanym schematem."""
@router.post("/suggest", response_model=ShoppingSuggestionResponse)
def suggest_shopping(session: Session = Depends(get_session)):
context = _build_shopping_context(session)
shopping_products = _get_shopping_products(session)
prompt = (
f"Na podstawie poniższych danych przeanalizuj, jakie TYPY produktów "
f"mogłyby uzupełnić rutynę pielęgnacyjną użytkownika.\n\n"
f"{context}\n\n"
"NARZEDZIA:\n"
"- Masz dostep do funkcji: get_product_inci, get_product_safety_rules, get_product_actives, get_product_usage_notes.\n"
"- Wywoluj narzedzia tylko, gdy potrzebujesz detali do oceny konfliktow skladnikow lub ryzyka podraznien.\n"
"- Grupuj UUID: staraj sie pobierac dane dla wielu produktow jednym wywolaniem.\n"
f"Zwróć wyłącznie JSON zgodny ze schematem."
)
config = get_creative_config(
system_instruction=_SHOPPING_SYSTEM_PROMPT,
response_schema=_ShoppingSuggestionsOut,
max_output_tokens=4096,
).model_copy(
update={
"tools": [
genai_types.Tool(
function_declarations=[
_INCI_FUNCTION_DECLARATION,
_SAFETY_RULES_FUNCTION_DECLARATION,
_ACTIVES_FUNCTION_DECLARATION,
_USAGE_NOTES_FUNCTION_DECLARATION,
]
)
],
"tool_config": genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(
mode=genai_types.FunctionCallingConfigMode.AUTO,
)
),
}
)
function_handlers = {
"get_product_inci": _build_inci_tool_handler(shopping_products),
"get_product_safety_rules": _build_safety_rules_tool_handler(shopping_products),
"get_product_actives": _build_actives_tool_handler(shopping_products),
"get_product_usage_notes": _build_usage_notes_tool_handler(shopping_products),
}
try:
response = call_gemini_with_function_tools(
endpoint="products/suggest",
contents=prompt,
config=config,
function_handlers=function_handlers,
user_input=prompt,
max_tool_roundtrips=3,
)
except HTTPException as exc:
if (
exc.status_code != 502
or str(exc.detail) != "Gemini requested too many function calls"
):
raise
conservative_prompt = (
f"{prompt}\n\n"
"TRYB AWARYJNY (KONSERWATYWNY):\n"
"- Osiagnieto limit wywolan narzedzi.\n"
"- Nie wywoluj narzedzi ponownie.\n"
"- Zasugeruj tylko najbardziej bezpieczne i realistyczne typy produktow do uzupelnienia brakow,"
" unikaj agresywnych aktywnych przy niepelnych danych.\n"
)
response = call_gemini(
endpoint="products/suggest",
contents=conservative_prompt,
config=get_creative_config(
system_instruction=_SHOPPING_SYSTEM_PROMPT,
response_schema=_ShoppingSuggestionsOut,
max_output_tokens=4096,
),
user_input=conservative_prompt,
tool_trace={
"mode": "fallback_conservative",
"reason": "max_tool_roundtrips_exceeded",
},
)
raw = response.text
if not raw:
raise HTTPException(status_code=502, detail="LLM returned an empty response")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
return ShoppingSuggestionResponse(
suggestions=[ProductSuggestion(**s) for s in parsed.get("suggestions", [])],
reasoning=parsed.get("reasoning", ""),
)

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,7 @@ from sqlmodel import Session, SQLModel, select
from db import get_session
from innercontext.api.utils import get_or_404
from innercontext.llm import get_gemini_client
from innercontext.llm import call_gemini, get_extraction_config
from innercontext.models import (
SkinConditionSnapshot,
SkinConditionSnapshotBase,
@ -140,37 +140,44 @@ async def analyze_skin_photos(
if not (1 <= len(photos) <= 3):
raise HTTPException(status_code=422, detail="Send between 1 and 3 photos.")
client, model = get_gemini_client()
allowed = {"image/jpeg", "image/png", "image/webp"}
allowed = {
"image/heic",
"image/heif",
"image/jpeg",
"image/png",
"image/webp",
}
parts: list[genai_types.Part] = []
for photo in photos:
if photo.content_type not in allowed:
raise HTTPException(status_code=422, detail=f"Unsupported type: {photo.content_type}")
raise HTTPException(
status_code=422, detail=f"Unsupported type: {photo.content_type}"
)
data = await photo.read()
if len(data) > MAX_IMAGE_BYTES:
raise HTTPException(status_code=413, detail=f"{photo.filename} exceeds 5 MB.")
parts.append(genai_types.Part.from_bytes(data=data, mime_type=photo.content_type))
raise HTTPException(
status_code=413, detail=f"{photo.filename} exceeds 5 MB."
)
parts.append(
genai_types.Part.from_bytes(data=data, mime_type=photo.content_type)
)
parts.append(
genai_types.Part.from_text(
text="Analyze the skin condition visible in the above photo(s) and return the JSON assessment."
)
)
try:
response = client.models.generate_content(
model=model,
contents=parts,
config=genai_types.GenerateContentConfig(
system_instruction=_skin_photo_system_prompt(),
response_mime_type="application/json",
response_schema=_SkinAnalysisOut,
max_output_tokens=2048,
temperature=0.0,
),
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Gemini API error: {e}")
image_summary = f"{len(photos)} image(s): {', '.join((p.content_type or 'unknown') for p in photos)}"
response = call_gemini(
endpoint="skincare/analyze-photos",
contents=parts,
config=get_extraction_config(
system_instruction=_skin_photo_system_prompt(),
response_schema=_SkinAnalysisOut,
max_output_tokens=2048,
),
user_input=image_summary,
)
try:
parsed = json.loads(response.text)

View file

@ -1,12 +1,53 @@
"""Shared helpers for Gemini API access."""
import os
import time
from collections.abc import Callable
from contextlib import suppress
from typing import Any
from fastapi import HTTPException
from google import genai
from google.genai import types as genai_types
_DEFAULT_MODEL = "gemini-3-flash-preview"
_DEFAULT_MODEL = "gemini-flash-latest"
def get_extraction_config(
system_instruction: str,
response_schema: Any,
max_output_tokens: int = 8192,
) -> genai_types.GenerateContentConfig:
"""Config for strict data extraction (deterministic, minimal thinking)."""
return genai_types.GenerateContentConfig(
system_instruction=system_instruction,
response_mime_type="application/json",
response_schema=response_schema,
max_output_tokens=max_output_tokens,
temperature=0.0,
thinking_config=genai_types.ThinkingConfig(
thinking_level=genai_types.ThinkingLevel.MINIMAL
),
)
def get_creative_config(
system_instruction: str,
response_schema: Any,
max_output_tokens: int = 4096,
) -> genai_types.GenerateContentConfig:
"""Config for creative tasks like recommendations (balanced creativity)."""
return genai_types.GenerateContentConfig(
system_instruction=system_instruction,
response_mime_type="application/json",
response_schema=response_schema,
max_output_tokens=max_output_tokens,
temperature=0.4,
top_p=0.8,
thinking_config=genai_types.ThinkingConfig(
thinking_level=genai_types.ThinkingLevel.LOW
),
)
def get_gemini_client() -> tuple[genai.Client, str]:
@ -19,3 +60,196 @@ def get_gemini_client() -> tuple[genai.Client, str]:
raise HTTPException(status_code=503, detail="GEMINI_API_KEY not configured")
model = os.environ.get("GEMINI_MODEL", _DEFAULT_MODEL)
return genai.Client(api_key=api_key), model
def call_gemini(
*,
endpoint: str,
contents,
config: genai_types.GenerateContentConfig,
user_input: str | None = None,
tool_trace: dict[str, Any] | None = None,
):
"""Call Gemini, log full request + response to DB, return response unchanged."""
from sqlmodel import Session
from db import engine # deferred to avoid circular import at module load
from innercontext.models.ai_log import AICallLog
client, model = get_gemini_client()
sys_prompt = None
if config.system_instruction:
raw = config.system_instruction
sys_prompt = raw if isinstance(raw, str) else str(raw)
if user_input is None:
with suppress(Exception):
user_input = str(contents)
start = time.monotonic()
success, error_detail, response, finish_reason = True, None, None, None
try:
response = client.models.generate_content(
model=model, contents=contents, config=config
)
candidates = getattr(response, "candidates", None)
if candidates:
first_candidate = candidates[0]
reason = getattr(first_candidate, "finish_reason", None)
reason_name = getattr(reason, "name", None)
if isinstance(reason_name, str):
finish_reason = reason_name
if finish_reason and finish_reason != "STOP":
success = False
error_detail = f"finish_reason: {finish_reason}"
raise HTTPException(
status_code=502,
detail=f"Gemini stopped early (finish_reason={finish_reason})",
)
except HTTPException:
raise
except Exception as exc:
success = False
error_detail = str(exc)
raise HTTPException(status_code=502, detail=f"Gemini API error: {exc}") from exc
finally:
duration_ms = int((time.monotonic() - start) * 1000)
with suppress(Exception):
log = AICallLog(
endpoint=endpoint,
model=model,
system_prompt=sys_prompt,
user_input=user_input,
response_text=response.text if response else None,
tool_trace=tool_trace,
prompt_tokens=(
response.usage_metadata.prompt_token_count
if response and response.usage_metadata
else None
),
completion_tokens=(
response.usage_metadata.candidates_token_count
if response and response.usage_metadata
else None
),
total_tokens=(
response.usage_metadata.total_token_count
if response and response.usage_metadata
else None
),
duration_ms=duration_ms,
finish_reason=finish_reason,
success=success,
error_detail=error_detail,
)
with Session(engine) as s:
s.add(log)
s.commit()
return response
def call_gemini_with_function_tools(
*,
endpoint: str,
contents,
config: genai_types.GenerateContentConfig,
function_handlers: dict[str, Callable[[dict[str, Any]], dict[str, Any]]],
user_input: str | None = None,
max_tool_roundtrips: int = 2,
):
"""Call Gemini with function-calling loop until final response text is produced."""
if max_tool_roundtrips < 0:
raise ValueError("max_tool_roundtrips must be >= 0")
history = list(contents) if isinstance(contents, list) else [contents]
rounds = 0
trace_events: list[dict[str, Any]] = []
while True:
response = call_gemini(
endpoint=endpoint,
contents=history,
config=config,
user_input=user_input,
tool_trace={
"mode": "function_tools",
"round": rounds,
"events": trace_events,
},
)
function_calls = list(getattr(response, "function_calls", None) or [])
if not function_calls:
return response
if rounds >= max_tool_roundtrips:
raise HTTPException(
status_code=502,
detail="Gemini requested too many function calls",
)
candidate_content = None
candidates = getattr(response, "candidates", None) or []
if candidates:
candidate_content = getattr(candidates[0], "content", None)
if candidate_content is not None:
history.append(candidate_content)
else:
history.append(
genai_types.ModelContent(
parts=[genai_types.Part(function_call=fc) for fc in function_calls]
)
)
response_parts: list[genai_types.Part] = []
for fc in function_calls:
name = getattr(fc, "name", None)
if not isinstance(name, str) or not name:
raise HTTPException(
status_code=502,
detail="Gemini requested a function without a valid name",
)
handler = function_handlers.get(name)
if handler is None:
raise HTTPException(
status_code=502,
detail=f"Gemini requested unknown function: {name}",
)
args = getattr(fc, "args", None) or {}
if not isinstance(args, dict):
raise HTTPException(
status_code=502,
detail=f"Gemini returned invalid arguments for function: {name}",
)
tool_response = handler(args)
if not isinstance(tool_response, dict):
raise HTTPException(
status_code=502,
detail=f"Function handler must return an object for: {name}",
)
trace_event: dict[str, Any] = {
"round": rounds + 1,
"function": name,
}
product_ids = args.get("product_ids")
if isinstance(product_ids, list):
clean_ids = [x for x in product_ids if isinstance(x, str)]
trace_event["requested_ids_count"] = len(clean_ids)
trace_event["requested_ids"] = clean_ids[:8]
products = tool_response.get("products")
if isinstance(products, list):
trace_event["returned_products_count"] = len(products)
trace_events.append(trace_event)
response_parts.append(
genai_types.Part.from_function_response(
name=name,
response=tool_response,
)
)
history.append(genai_types.UserContent(parts=response_parts))
rounds += 1

View file

@ -1,438 +0,0 @@
from __future__ import annotations
from datetime import date, timedelta
from typing import Optional
from uuid import UUID
from fastmcp import FastMCP
from sqlmodel import Session, col, select
from db import engine
from innercontext.models import (
GroomingSchedule,
LabResult,
MedicationEntry,
MedicationUsage,
Product,
ProductInventory,
Routine,
RoutineStep,
SkinConditionSnapshot,
)
mcp = FastMCP("innercontext")
# ── Products ──────────────────────────────────────────────────────────────────
@mcp.tool()
def get_products(
category: Optional[str] = None,
is_medication: bool = False,
is_tool: bool = False,
) -> list[dict]:
"""List products. By default returns skincare products (excludes medications and tools).
Pass is_medication=True or is_tool=True to retrieve those categories instead."""
with Session(engine) as session:
stmt = select(Product)
if category is not None:
stmt = stmt.where(Product.category == category)
stmt = stmt.where(Product.is_medication == is_medication)
stmt = stmt.where(Product.is_tool == is_tool)
products = session.exec(stmt).all()
return [p.to_llm_context() for p in products]
@mcp.tool()
def get_product(product_id: str) -> dict:
"""Get full context for a single product (UUID) including all inventory entries."""
with Session(engine) as session:
product = session.get(Product, UUID(product_id))
if product is None:
return {"error": f"Product {product_id} not found"}
ctx = product.to_llm_context()
entries = session.exec(
select(ProductInventory).where(ProductInventory.product_id == product.id)
).all()
ctx["inventory"] = [
{
"id": str(inv.id),
"is_opened": inv.is_opened,
"opened_at": inv.opened_at.isoformat() if inv.opened_at else None,
"finished_at": inv.finished_at.isoformat() if inv.finished_at else None,
"expiry_date": inv.expiry_date.isoformat() if inv.expiry_date else None,
"current_weight_g": inv.current_weight_g,
}
for inv in entries
]
return ctx
# ── Inventory ─────────────────────────────────────────────────────────────────
@mcp.tool()
def get_open_inventory() -> list[dict]:
"""Return all currently open packages (is_opened=True, finished_at=None)
with product name, opening date, weight, and expiry date."""
with Session(engine) as session:
stmt = (
select(ProductInventory, Product)
.join(Product, ProductInventory.product_id == Product.id)
.where(ProductInventory.is_opened == True) # noqa: E712
.where(ProductInventory.finished_at == None) # noqa: E711
)
rows = session.exec(stmt).all()
return [
{
"inventory_id": str(inv.id),
"product_id": str(product.id),
"product_name": product.name,
"brand": product.brand,
"opened_at": inv.opened_at.isoformat() if inv.opened_at else None,
"current_weight_g": inv.current_weight_g,
"expiry_date": inv.expiry_date.isoformat() if inv.expiry_date else None,
}
for inv, product in rows
]
# ── Routines ──────────────────────────────────────────────────────────────────
@mcp.tool()
def get_recent_routines(days: int = 14) -> list[dict]:
"""Get skincare routines from the last N days, newest first.
Each routine includes its ordered steps with product name or action."""
with Session(engine) as session:
cutoff = date.today() - timedelta(days=days)
routines = session.exec(
select(Routine)
.where(Routine.routine_date >= cutoff)
.order_by(col(Routine.routine_date).desc())
).all()
result = []
for routine in routines:
steps = session.exec(
select(RoutineStep)
.where(RoutineStep.routine_id == routine.id)
.order_by(RoutineStep.order_index)
).all()
steps_data = []
for step in steps:
step_dict: dict = {"order": step.order_index}
if step.product_id:
product = session.get(Product, step.product_id)
if product:
step_dict["product"] = product.name
step_dict["product_id"] = str(product.id)
if step.action_type:
step_dict["action"] = (
step.action_type.value
if hasattr(step.action_type, "value")
else str(step.action_type)
)
if step.action_notes:
step_dict["notes"] = step.action_notes
if step.dose:
step_dict["dose"] = step.dose
if step.region:
step_dict["region"] = step.region
steps_data.append(step_dict)
result.append(
{
"id": str(routine.id),
"date": routine.routine_date.isoformat(),
"part_of_day": (
routine.part_of_day.value
if hasattr(routine.part_of_day, "value")
else str(routine.part_of_day)
),
"notes": routine.notes,
"steps": steps_data,
}
)
return result
# ── Skin snapshots ────────────────────────────────────────────────────────────
def _snapshot_to_dict(s: SkinConditionSnapshot, *, full: bool) -> dict:
def ev(v: object) -> object:
return v.value if v is not None and hasattr(v, "value") else v
d: dict = {
"id": str(s.id),
"date": s.snapshot_date.isoformat(),
"overall_state": ev(s.overall_state),
"hydration_level": s.hydration_level,
"sensitivity_level": s.sensitivity_level,
"barrier_state": ev(s.barrier_state),
"active_concerns": [ev(c) for c in (s.active_concerns or [])],
}
if full:
d.update(
{
"skin_type": ev(s.skin_type),
"texture": ev(s.texture),
"sebum_tzone": s.sebum_tzone,
"sebum_cheeks": s.sebum_cheeks,
"risks": s.risks or [],
"priorities": s.priorities or [],
"notes": s.notes,
}
)
return d
@mcp.tool()
def get_latest_skin_snapshot() -> dict | None:
"""Get the most recent skin condition snapshot with all metrics."""
with Session(engine) as session:
snapshot = session.exec(
select(SkinConditionSnapshot).order_by(
col(SkinConditionSnapshot.snapshot_date).desc()
)
).first()
if snapshot is None:
return None
return _snapshot_to_dict(snapshot, full=True)
@mcp.tool()
def get_skin_history(weeks: int = 8) -> list[dict]:
"""Get skin condition snapshots from the last N weeks with key metrics."""
with Session(engine) as session:
cutoff = date.today() - timedelta(weeks=weeks)
snapshots = session.exec(
select(SkinConditionSnapshot)
.where(SkinConditionSnapshot.snapshot_date >= cutoff)
.order_by(col(SkinConditionSnapshot.snapshot_date).desc())
).all()
return [_snapshot_to_dict(s, full=False) for s in snapshots]
@mcp.tool()
def get_skin_snapshot_dates() -> list[str]:
"""List all dates (YYYY-MM-DD) for which skin snapshots exist, newest first."""
with Session(engine) as session:
snapshots = session.exec(
select(SkinConditionSnapshot).order_by(
col(SkinConditionSnapshot.snapshot_date).desc()
)
).all()
return [s.snapshot_date.isoformat() for s in snapshots]
@mcp.tool()
def get_skin_snapshot(snapshot_date: str) -> dict | None:
"""Get the full skin condition snapshot for a specific date (YYYY-MM-DD)."""
with Session(engine) as session:
target = date.fromisoformat(snapshot_date)
snapshot = session.exec(
select(SkinConditionSnapshot).where(
SkinConditionSnapshot.snapshot_date == target
)
).first()
if snapshot is None:
return None
return _snapshot_to_dict(snapshot, full=True)
# ── Health / medications ───────────────────────────────────────────────────────
@mcp.tool()
def get_medications() -> list[dict]:
"""Get all medication entries with their currently active usage records
(valid_to IS NULL or >= today)."""
with Session(engine) as session:
medications = session.exec(select(MedicationEntry)).all()
today = date.today()
result = []
for med in medications:
usages = session.exec(
select(MedicationUsage)
.where(MedicationUsage.medication_record_id == med.record_id)
.where(
(MedicationUsage.valid_to == None) # noqa: E711
| (col(MedicationUsage.valid_to) >= today)
)
).all()
result.append(
{
"id": str(med.record_id),
"product_name": med.product_name,
"kind": (
med.kind.value if hasattr(med.kind, "value") else str(med.kind)
),
"active_substance": med.active_substance,
"formulation": med.formulation,
"route": med.route,
"notes": med.notes,
"active_usages": [
{
"id": str(u.record_id),
"dose": (
f"{u.dose_value} {u.dose_unit}"
if u.dose_value is not None and u.dose_unit
else None
),
"frequency": u.frequency,
"schedule_text": u.schedule_text,
"as_needed": u.as_needed,
"valid_from": u.valid_from.isoformat()
if u.valid_from
else None,
"valid_to": u.valid_to.isoformat() if u.valid_to else None,
}
for u in usages
],
}
)
return result
# ── Expiring inventory ────────────────────────────────────────────────────────
@mcp.tool()
def get_expiring_inventory(days: int = 30) -> list[dict]:
"""List open packages whose expiry date falls within the next N days.
Sorted by days remaining (soonest first)."""
with Session(engine) as session:
cutoff = date.today() + timedelta(days=days)
stmt = (
select(ProductInventory, Product)
.join(Product, ProductInventory.product_id == Product.id)
.where(ProductInventory.is_opened == True) # noqa: E712
.where(ProductInventory.finished_at == None) # noqa: E711
.where(ProductInventory.expiry_date != None) # noqa: E711
.where(col(ProductInventory.expiry_date) <= cutoff)
)
rows = session.exec(stmt).all()
today = date.today()
result = [
{
"product_name": product.name,
"brand": product.brand,
"expiry_date": inv.expiry_date.isoformat() if inv.expiry_date else None,
"days_remaining": (inv.expiry_date - today).days
if inv.expiry_date
else None,
"current_weight_g": inv.current_weight_g,
}
for inv, product in rows
]
return sorted(result, key=lambda x: x["days_remaining"] or 0)
# ── Grooming schedule ─────────────────────────────────────────────────────────
@mcp.tool()
def get_grooming_schedule() -> list[dict]:
"""Get the full grooming schedule sorted by day of week (0=Monday, 6=Sunday)."""
with Session(engine) as session:
entries = session.exec(
select(GroomingSchedule).order_by(GroomingSchedule.day_of_week)
).all()
return [
{
"id": str(e.id),
"day_of_week": e.day_of_week,
"action": (
e.action.value if hasattr(e.action, "value") else str(e.action)
),
"notes": e.notes,
}
for e in entries
]
# ── Lab results ───────────────────────────────────────────────────────────────
def _lab_result_to_dict(r: LabResult) -> dict:
return {
"id": str(r.record_id),
"collected_at": r.collected_at.isoformat(),
"test_code": r.test_code,
"test_name_loinc": r.test_name_loinc,
"test_name_original": r.test_name_original,
"value_num": r.value_num,
"value_text": r.value_text,
"value_bool": r.value_bool,
"unit": r.unit_ucum or r.unit_original,
"ref_low": r.ref_low,
"ref_high": r.ref_high,
"ref_text": r.ref_text,
"flag": r.flag.value if r.flag and hasattr(r.flag, "value") else r.flag,
"lab": r.lab,
"notes": r.notes,
}
@mcp.tool()
def get_recent_lab_results(limit: int = 30) -> list[dict]:
"""Get the most recent lab results sorted by collection date descending."""
with Session(engine) as session:
results = session.exec(
select(LabResult)
.order_by(col(LabResult.collected_at).desc())
.limit(limit)
).all()
return [_lab_result_to_dict(r) for r in results]
@mcp.tool()
def get_available_lab_tests() -> list[dict]:
"""List all distinct lab tests ever performed, grouped by LOINC test_code.
Returns test_code, LOINC name, original lab names, result count, and last collection date."""
with Session(engine) as session:
results = session.exec(select(LabResult)).all()
tests: dict[str, dict] = {}
for r in results:
code = r.test_code
if code not in tests:
tests[code] = {
"test_code": code,
"test_name_loinc": r.test_name_loinc,
"test_names_original": set(),
"count": 0,
"last_collected_at": r.collected_at,
}
tests[code]["count"] += 1
if r.test_name_original:
tests[code]["test_names_original"].add(r.test_name_original)
if r.collected_at > tests[code]["last_collected_at"]:
tests[code]["last_collected_at"] = r.collected_at
return [
{
"test_code": v["test_code"],
"test_name_loinc": v["test_name_loinc"],
"test_names_original": sorted(v["test_names_original"]),
"count": v["count"],
"last_collected_at": v["last_collected_at"].isoformat(),
}
for v in sorted(tests.values(), key=lambda x: x["test_code"])
]
@mcp.tool()
def get_lab_results_for_test(test_code: str) -> list[dict]:
"""Get the full chronological history of results for a specific LOINC test code."""
with Session(engine) as session:
results = session.exec(
select(LabResult)
.where(LabResult.test_code == test_code)
.order_by(col(LabResult.collected_at).asc())
).all()
return [_lab_result_to_dict(r) for r in results]

View file

@ -1,3 +1,4 @@
from .ai_log import AICallLog
from .domain import Domain
from .enums import (
AbsorptionSpeed,
@ -41,6 +42,8 @@ from .skincare import (
)
__all__ = [
# ai logs
"AICallLog",
# domain
"Domain",
# enums

View file

@ -0,0 +1,33 @@
from datetime import datetime
from typing import Any, ClassVar
from uuid import UUID, uuid4
from sqlalchemy import JSON, Column
from sqlmodel import Field, SQLModel
from .base import utc_now
from .domain import Domain
class AICallLog(SQLModel, table=True):
__tablename__ = "ai_call_logs"
__domains__: ClassVar[frozenset[Domain]] = frozenset()
id: UUID = Field(default_factory=uuid4, primary_key=True)
created_at: datetime = Field(default_factory=utc_now, nullable=False)
endpoint: str = Field(index=True)
model: str
system_prompt: str | None = Field(default=None)
user_input: str | None = Field(default=None)
response_text: str | None = Field(default=None)
prompt_tokens: int | None = Field(default=None)
completion_tokens: int | None = Field(default=None)
total_tokens: int | None = Field(default=None)
duration_ms: int | None = Field(default=None)
finish_reason: str | None = Field(default=None)
tool_trace: dict[str, Any] | None = Field(
default=None,
sa_column=Column(JSON, nullable=True),
)
success: bool = Field(default=True, index=True)
error_detail: str | None = Field(default=None)

View file

@ -1,4 +1,5 @@
from contextlib import asynccontextmanager
from typing import AsyncIterator
from dotenv import load_dotenv
@ -6,30 +7,27 @@ load_dotenv() # load .env before db.py reads DATABASE_URL
from fastapi import FastAPI # noqa: E402
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
from fastmcp.utilities.lifespan import combine_lifespans # noqa: E402
from db import create_db_and_tables # noqa: E402
from innercontext.api import ( # noqa: E402
ai_logs,
health,
inventory,
products,
routines,
skincare,
)
from innercontext.mcp_server import mcp # noqa: E402
mcp_app = mcp.http_app(path="/mcp")
@asynccontextmanager
async def lifespan(app: FastAPI):
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
create_db_and_tables()
yield
app = FastAPI(
title="innercontext API",
lifespan=combine_lifespans(lifespan, mcp_app.lifespan),
lifespan=lifespan,
redirect_slashes=False,
)
@ -45,9 +43,7 @@ app.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
app.include_router(health.router, prefix="/health", tags=["health"])
app.include_router(routines.router, prefix="/routines", tags=["routines"])
app.include_router(skincare.router, prefix="/skincare", tags=["skincare"])
app.mount("/mcp", mcp_app)
app.include_router(ai_logs.router, prefix="/ai-logs", tags=["ai-logs"])
@app.get("/health-check")

View file

@ -7,7 +7,6 @@ requires-python = ">=3.12"
dependencies = [
"alembic>=1.14",
"fastapi>=0.132.0",
"fastmcp>=2.0",
"google-genai>=1.65.0",
"psycopg>=3.3.3",
"python-dotenv>=1.2.1",
@ -22,6 +21,7 @@ dev = [
"httpx>=0.28.1",
"isort>=8.0.0",
"pytest>=9.0.2",
"pytest-cov>=6.0.0",
"ruff>=0.15.2",
"ty>=0.0.18",
]
@ -29,7 +29,7 @@ dev = [
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
addopts = "-v --tb=short"
addopts = "-v --tb=short --cov=innercontext --cov-report=term-missing --cov-report=html"
[tool.isort]
profile = "black"

25
backend/test_query.py Normal file
View file

@ -0,0 +1,25 @@
from datetime import date, timedelta
from sqlmodel import select
from db import get_session
from innercontext.models import Routine, RoutineStep
def run():
session = next(get_session())
ref_date = date.today()
cutoff = ref_date - timedelta(days=7)
recent_usage = session.exec(
select(RoutineStep.product_id)
.join(Routine, Routine.id == RoutineStep.routine_id)
.where(Routine.routine_date >= cutoff)
.where(Routine.routine_date <= ref_date)
).all()
print("Found:", len(recent_usage))
if __name__ == "__main__":
run()

View file

@ -1,5 +1,4 @@
import os
from contextlib import asynccontextmanager
# Must be set before importing db (which calls create_engine at module level)
os.environ.setdefault("DATABASE_URL", "sqlite://")
@ -14,18 +13,6 @@ from db import get_session
from main import app
@asynccontextmanager
async def _db_only_lifespan(a):
"""Lifespan without the MCP server for test isolation.
StreamableHTTPSessionManager.run() can only be called once per instance,
which conflicts with the per-test TestClient lifecycle. We replace the
combined (db + MCP) lifespan with one that only does DB setup.
"""
db_module.create_db_and_tables()
yield
@pytest.fixture()
def session(monkeypatch):
"""Per-test fresh SQLite in-memory database with full isolation."""
@ -47,9 +34,6 @@ def session(monkeypatch):
@pytest.fixture()
def client(session, monkeypatch):
"""TestClient using the per-test session for every request."""
# Replace combined (db+MCP) lifespan with DB-only to avoid the
# StreamableHTTPSessionManager single-run limitation.
monkeypatch.setattr(app.router, "lifespan_context", _db_only_lifespan)
def _override():
yield session

View file

@ -0,0 +1,44 @@
import uuid
from typing import Any, cast
from innercontext.models.ai_log import AICallLog
def test_list_ai_logs_normalizes_tool_trace_string(client, session):
log = AICallLog(
id=uuid.uuid4(),
endpoint="routines/suggest",
model="gemini-3-flash-preview",
success=True,
)
log.tool_trace = cast(
Any,
'{"mode":"function_tools","events":[{"function":"get_product_inci"}]}',
)
session.add(log)
session.commit()
response = client.get("/ai-logs")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["tool_trace"]["mode"] == "function_tools"
assert data[0]["tool_trace"]["events"][0]["function"] == "get_product_inci"
def test_get_ai_log_normalizes_tool_trace_string(client, session):
log = AICallLog(
id=uuid.uuid4(),
endpoint="routines/suggest",
model="gemini-3-flash-preview",
success=True,
)
log.tool_trace = cast(Any, '{"mode":"function_tools","round":1}')
session.add(log)
session.commit()
response = client.get(f"/ai-logs/{log.id}")
assert response.status_code == 200
payload = response.json()
assert payload["tool_trace"]["mode"] == "function_tools"
assert payload["tool_trace"]["round"] == 1

View file

@ -199,3 +199,22 @@ def test_create_inventory(client, created_product):
def test_create_inventory_product_not_found(client):
r = client.post(f"/products/{uuid.uuid4()}/inventory", json={})
assert r.status_code == 404
def test_parse_text_accepts_numeric_strength_levels(client, monkeypatch):
from innercontext.api import products as products_api
class _FakeResponse:
text = (
'{"name":"Test Serum","actives":[{"name":"Niacinamide","percent":10,'
'"functions":["niacinamide"],"strength_level":2,"irritation_potential":1}]}'
)
monkeypatch.setattr(products_api, "call_gemini", lambda **kwargs: _FakeResponse())
r = client.post("/products/parse-text", json={"text": "dummy input"})
assert r.status_code == 200
data = r.json()
assert data["name"] == "Test Serum"
assert data["actives"][0]["strength_level"] == 2
assert data["actives"][0]["irritation_potential"] == 1

View file

@ -0,0 +1,157 @@
import uuid
from datetime import date
from unittest.mock import patch
from sqlmodel import Session
from innercontext.api.products import (
_build_actives_tool_handler,
_build_inci_tool_handler,
_build_safety_rules_tool_handler,
_build_shopping_context,
_build_usage_notes_tool_handler,
_extract_requested_product_ids,
)
from innercontext.models import Product, ProductInventory, SkinConditionSnapshot
def test_build_shopping_context(session: Session):
# Empty context
ctx = _build_shopping_context(session)
assert "(brak danych)" in ctx
assert "POSIADANE PRODUKTY" in ctx
# Add snapshot
snap = SkinConditionSnapshot(
id=uuid.uuid4(),
snapshot_date=date.today(),
overall_state="fair",
skin_type="combination",
hydration_level=3,
sensitivity_level=4,
barrier_state="mildly_compromised",
active_concerns=["redness"],
priorities=["soothing"],
)
session.add(snap)
# Add product
p = Product(
id=uuid.uuid4(),
name="Soothing Serum",
brand="BrandX",
category="serum",
recommended_time="both",
leave_on=True,
targets=["redness"],
product_effect_profile={"soothing_strength": 4, "hydration_immediate": 1},
actives=[{"name": "Centella"}],
)
session.add(p)
session.commit()
# Add inventory
inv = ProductInventory(id=uuid.uuid4(), product_id=p.id, is_opened=True)
session.add(inv)
session.commit()
ctx = _build_shopping_context(session)
assert "Typ skóry: combination" in ctx
assert "Nawilżenie: 3/5" in ctx
assert "Wrażliwość: 4/5" in ctx
assert "Aktywne problemy: redness" in ctx
assert "Priorytety: soothing" in ctx
# Check product
assert "[✓] id=" in ctx
assert "Soothing Serum" in ctx
assert f"id={p.id}" in ctx
assert "BrandX" in ctx
assert "targets: ['redness']" in ctx
assert "actives: ['Centella']" in ctx
assert "effects: {'soothing': 4}" in ctx
def test_suggest_shopping(client, session):
with patch(
"innercontext.api.products.call_gemini_with_function_tools"
) as mock_gemini:
mock_response = type(
"Response",
(),
{
"text": '{"suggestions": [{"category": "cleanser", "product_type": "cleanser", "priority": "high", "key_ingredients": [], "target_concerns": [], "why_needed": "reason", "recommended_time": "am", "frequency": "daily"}], "reasoning": "Test shopping"}'
},
)
mock_gemini.return_value = mock_response
r = client.post("/products/suggest")
assert r.status_code == 200
data = r.json()
assert len(data["suggestions"]) == 1
assert data["suggestions"][0]["product_type"] == "cleanser"
assert data["reasoning"] == "Test shopping"
kwargs = mock_gemini.call_args.kwargs
assert "function_handlers" in kwargs
assert "get_product_inci" in kwargs["function_handlers"]
assert "get_product_safety_rules" in kwargs["function_handlers"]
assert "get_product_actives" in kwargs["function_handlers"]
assert "get_product_usage_notes" in kwargs["function_handlers"]
def test_shopping_context_medication_skip(session: Session):
p = Product(
id=uuid.uuid4(),
name="Epiduo",
brand="Galderma",
category="serum",
recommended_time="pm",
leave_on=True,
is_medication=True,
product_effect_profile={},
)
session.add(p)
session.commit()
ctx = _build_shopping_context(session)
assert "Epiduo" not in ctx
def test_extract_requested_product_ids_dedupes_and_limits():
ids = _extract_requested_product_ids(
{"product_ids": ["a", "b", "a", 1, "c", "d"]},
max_ids=3,
)
assert ids == ["a", "b", "c"]
def test_shopping_tool_handlers_return_payloads(session: Session):
product = Product(
id=uuid.uuid4(),
name="Test Product",
brand="Brand",
category="serum",
recommended_time="both",
leave_on=True,
usage_notes="Use AM and PM on clean skin.",
inci=["Water", "Niacinamide"],
actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}],
incompatible_with=[{"target": "Vitamin C", "scope": "same_step"}],
context_rules={"safe_after_shaving": True},
product_effect_profile={},
)
payload = {"product_ids": [str(product.id)]}
inci_data = _build_inci_tool_handler([product])(payload)
assert inci_data["products"][0]["inci"] == ["Water", "Niacinamide"]
actives_data = _build_actives_tool_handler([product])(payload)
assert actives_data["products"][0]["actives"][0]["name"] == "Niacinamide"
notes_data = _build_usage_notes_tool_handler([product])(payload)
assert notes_data["products"][0]["usage_notes"] == "Use AM and PM on clean skin."
safety_data = _build_safety_rules_tool_handler([product])(payload)
assert "incompatible_with" in safety_data["products"][0]
assert "context_rules" in safety_data["products"][0]

View file

@ -1,4 +1,5 @@
import uuid
from unittest.mock import patch
# ---------------------------------------------------------------------------
# Routines
@ -216,3 +217,82 @@ def test_delete_grooming_schedule(client):
def test_delete_grooming_schedule_not_found(client):
r = client.delete(f"/routines/grooming-schedule/{uuid.uuid4()}")
assert r.status_code == 404
def test_suggest_routine(client, session):
with patch(
"innercontext.api.routines.call_gemini_with_function_tools"
) as mock_gemini:
# Mock the Gemini response
mock_response = type(
"Response",
(),
{
"text": '{"steps": [{"product_id": null, "action_type": "shaving_razor"}], "reasoning": "because"}'
},
)
mock_gemini.return_value = mock_response
r = client.post(
"/routines/suggest",
json={
"routine_date": "2026-03-03",
"part_of_day": "am",
"notes": "Testing",
"include_minoxidil_beard": True,
},
)
assert r.status_code == 200
data = r.json()
assert len(data["steps"]) == 1
assert data["steps"][0]["action_type"] == "shaving_razor"
assert data["reasoning"] == "because"
kwargs = mock_gemini.call_args.kwargs
assert "function_handlers" in kwargs
assert "get_product_inci" in kwargs["function_handlers"]
assert "get_product_safety_rules" in kwargs["function_handlers"]
assert "get_product_actives" in kwargs["function_handlers"]
assert "get_product_usage_notes" in kwargs["function_handlers"]
def test_suggest_batch(client, session):
with patch("innercontext.api.routines.call_gemini") as mock_gemini:
# Mock the Gemini response
mock_response = type(
"Response",
(),
{
"text": '{"days": [{"date": "2026-03-03", "am_steps": [], "pm_steps": [], "reasoning": "none"}], "overall_reasoning": "batch test"}'
},
)
mock_gemini.return_value = mock_response
r = client.post(
"/routines/suggest-batch",
json={
"from_date": "2026-03-03",
"to_date": "2026-03-04",
"minimize_products": True,
},
)
assert r.status_code == 200
data = r.json()
assert len(data["days"]) == 1
assert data["days"][0]["date"] == "2026-03-03"
assert data["overall_reasoning"] == "batch test"
def test_suggest_batch_invalid_date_range(client):
r = client.post(
"/routines/suggest-batch",
json={"from_date": "2026-03-04", "to_date": "2026-03-03"},
)
assert r.status_code == 400
def test_suggest_batch_too_long(client):
r = client.post(
"/routines/suggest-batch",
json={"from_date": "2026-03-01", "to_date": "2026-03-20"},
)
assert r.status_code == 400

View file

@ -0,0 +1,408 @@
import uuid
from datetime import date, timedelta
from sqlmodel import Session
from innercontext.api.routines import (
_build_actives_tool_handler,
_build_day_context,
_build_grooming_context,
_build_inci_tool_handler,
_build_objectives_context,
_build_products_context,
_build_recent_history,
_build_safety_rules_tool_handler,
_build_skin_context,
_build_usage_notes_tool_handler,
_contains_minoxidil_text,
_ev,
_extract_active_names,
_extract_requested_product_ids,
_get_available_products,
_is_minoxidil_product,
)
from innercontext.models import (
GroomingSchedule,
Product,
ProductInventory,
Routine,
RoutineStep,
SkinConditionSnapshot,
)
def test_contains_minoxidil_text():
assert _contains_minoxidil_text(None) is False
assert _contains_minoxidil_text("") is False
assert _contains_minoxidil_text("some random text") is False
assert _contains_minoxidil_text("contains MINOXIDIL here") is True
assert _contains_minoxidil_text("minoksydyl 5%") is True
def test_is_minoxidil_product():
# Setup product
p = Product(id=uuid.uuid4(), name="Test", brand="Brand", is_medication=True)
assert _is_minoxidil_product(p) is False
p.name = "Minoxidil 5%"
assert _is_minoxidil_product(p) is True
p.name = "Test"
p.brand = "Brand with minoksydyl"
assert _is_minoxidil_product(p) is True
p.brand = "Brand"
p.line_name = "Minoxidil Line"
assert _is_minoxidil_product(p) is True
p.line_name = None
p.usage_notes = "Use minoxidil daily"
assert _is_minoxidil_product(p) is True
p.usage_notes = None
p.inci = ["water", "minoxidil"]
assert _is_minoxidil_product(p) is True
p.inci = None
p.actives = [{"name": "minoxidil", "strength": "5%"}]
assert _is_minoxidil_product(p) is True
# As Pydantic model representation isn't exactly a dict in db sometimes, we just test dict
p.actives = [{"name": "Retinol", "strength": "1%"}]
assert _is_minoxidil_product(p) is False
def test_ev():
class DummyEnum:
value = "dummy"
assert _ev(None) == ""
assert _ev(DummyEnum()) == "dummy"
assert _ev("string") == "string"
def test_build_skin_context(session: Session):
# Empty
assert _build_skin_context(session) == "SKIN CONDITION: no data\n"
# With data
snap = SkinConditionSnapshot(
id=uuid.uuid4(),
snapshot_date=date.today(),
overall_state="good",
hydration_level=4,
barrier_state="intact",
active_concerns=["acne", "dryness"],
priorities=["hydration"],
notes="Feeling good",
)
session.add(snap)
session.commit()
ctx = _build_skin_context(session)
assert "SKIN CONDITION (snapshot from" in ctx
assert "Overall state: good" in ctx
assert "Hydration: 4/5" in ctx
assert "Barrier: intact" in ctx
assert "Active concerns: acne, dryness" in ctx
assert "Priorities: hydration" in ctx
assert "Notes: Feeling good" in ctx
def test_build_grooming_context(session: Session):
assert _build_grooming_context(session) == "GROOMING SCHEDULE: none\n"
sch = GroomingSchedule(
id=uuid.uuid4(), day_of_week=0, action="shaving_oneblade", notes="Morning"
)
session.add(sch)
session.commit()
ctx = _build_grooming_context(session)
assert "GROOMING SCHEDULE:" in ctx
assert "poniedziałek: shaving_oneblade (Morning)" in ctx
# Test weekdays filter
ctx2 = _build_grooming_context(session, weekdays=[1]) # not monday
assert "(no entries for specified days)" in ctx2
def test_build_recent_history(session: Session):
assert _build_recent_history(session) == "RECENT ROUTINES: none\n"
r = Routine(id=uuid.uuid4(), routine_date=date.today(), part_of_day="am")
session.add(r)
p = Product(
id=uuid.uuid4(),
name="Cleanser",
category="cleanser",
brand="Test",
recommended_time="both",
leave_on=False,
product_effect_profile={},
)
session.add(p)
session.commit()
s1 = RoutineStep(id=uuid.uuid4(), routine_id=r.id, order_index=1, product_id=p.id)
s2 = RoutineStep(
id=uuid.uuid4(), routine_id=r.id, order_index=2, action_type="shaving_razor"
)
# Step with non-existent product
s3 = RoutineStep(
id=uuid.uuid4(), routine_id=r.id, order_index=3, product_id=uuid.uuid4()
)
session.add_all([s1, s2, s3])
session.commit()
ctx = _build_recent_history(session)
assert "RECENT ROUTINES:" in ctx
assert "AM:" in ctx
assert "cleanser [" in ctx
assert "action: shaving_razor" in ctx
assert "unknown [" in ctx
def test_build_products_context(session: Session):
p1 = Product(
id=uuid.uuid4(),
name="Regaine",
category="serum",
is_medication=True,
brand="J&J",
recommended_time="both",
leave_on=True,
product_effect_profile={},
)
p2 = Product(
id=uuid.uuid4(),
name="Sunscreen",
category="spf",
brand="Test",
leave_on=True,
recommended_time="am",
pao_months=6,
product_effect_profile={"hydration_immediate": 2, "exfoliation_strength": 0},
incompatible_with=[{"target": "retinol", "scope": "same_routine"}],
context_rules={"safe_after_shaving": False},
min_interval_hours=12,
max_frequency_per_week=7,
)
session.add_all([p1, p2])
session.commit()
# Inventory
inv1 = ProductInventory(
id=uuid.uuid4(),
product_id=p2.id,
is_opened=True,
opened_at=date.today() - timedelta(days=10),
expiry_date=date.today() + timedelta(days=365),
)
inv2 = ProductInventory(id=uuid.uuid4(), product_id=p2.id, is_opened=False)
session.add_all([inv1, inv2])
session.commit()
# Usage
r = Routine(id=uuid.uuid4(), routine_date=date.today(), part_of_day="am")
session.add(r)
session.commit()
s = RoutineStep(id=uuid.uuid4(), routine_id=r.id, order_index=1, product_id=p2.id)
session.add(s)
session.commit()
ctx = _build_products_context(
session, time_filter="am", reference_date=date.today()
)
# p1 is medication but not minoxidil (wait, Regaine name doesn't contain minoxidil!) -> skipped
assert "Regaine" not in ctx
# Let's fix p1 to be minoxidil
p1.name = "Regaine Minoxidil"
session.add(p1)
session.commit()
ctx = _build_products_context(
session, time_filter="am", reference_date=date.today()
)
assert "Regaine Minoxidil" in ctx
assert "Sunscreen" in ctx
assert "inventory_status={active:2,opened:1,sealed:1}" in ctx
assert "nearest_open_expiry=" in ctx
assert "nearest_open_pao_deadline=" in ctx
assert "pao_months=6" in ctx
assert "effects={'hydration_immediate': 2}" in ctx
assert "incompatible_with=['avoid retinol (same_routine)']" in ctx
assert "context_rules={'safe_after_shaving': False}" in ctx
assert "min_interval_hours=12" in ctx
assert "max_frequency_per_week=7" in ctx
assert "used_in_last_7_days=1" in ctx
def test_build_objectives_context():
assert _build_objectives_context(False) == ""
assert "improve beard" in _build_objectives_context(True)
def test_build_day_context():
assert _build_day_context(None) == ""
assert "Leaving home: yes" in _build_day_context(True)
assert "Leaving home: no" in _build_day_context(False)
def test_get_available_products_respects_filters(session: Session):
regular_med = Product(
id=uuid.uuid4(),
name="Tretinoin",
category="serum",
is_medication=True,
brand="Test",
recommended_time="pm",
leave_on=True,
product_effect_profile={},
)
minoxidil_med = Product(
id=uuid.uuid4(),
name="Minoxidil 5%",
category="serum",
is_medication=True,
brand="Test",
recommended_time="both",
leave_on=True,
product_effect_profile={},
)
am_product = Product(
id=uuid.uuid4(),
name="AM SPF",
category="spf",
brand="Test",
recommended_time="am",
leave_on=True,
product_effect_profile={},
)
pm_product = Product(
id=uuid.uuid4(),
name="PM Cream",
category="moisturizer",
brand="Test",
recommended_time="pm",
leave_on=True,
product_effect_profile={},
)
session.add_all([regular_med, minoxidil_med, am_product, pm_product])
session.commit()
am_available = _get_available_products(session, time_filter="am")
am_names = {p.name for p in am_available}
assert "Tretinoin" not in am_names
assert "Minoxidil 5%" in am_names
assert "AM SPF" in am_names
assert "PM Cream" not in am_names
def test_build_inci_tool_handler_returns_only_available_ids(session: Session):
available = Product(
id=uuid.uuid4(),
name="Available",
category="serum",
brand="Test",
recommended_time="both",
leave_on=True,
inci=["Water", "Niacinamide"],
product_effect_profile={},
)
unavailable = Product(
id=uuid.uuid4(),
name="Unavailable",
category="serum",
brand="Test",
recommended_time="both",
leave_on=True,
inci=["Water", "Retinol"],
product_effect_profile={},
)
handler = _build_inci_tool_handler([available])
payload = handler(
{
"product_ids": [
str(available.id),
str(unavailable.id),
str(available.id),
123,
]
}
)
assert "products" in payload
products = payload["products"]
assert len(products) == 1
assert products[0]["id"] == str(available.id)
assert products[0]["name"] == "Available"
assert products[0]["inci"] == ["Water", "Niacinamide"]
def test_extract_requested_product_ids_dedupes_and_limits():
ids = _extract_requested_product_ids(
{
"product_ids": [
"id-1",
"id-2",
"id-1",
3,
"id-3",
"id-4",
]
},
max_ids=3,
)
assert ids == ["id-1", "id-2", "id-3"]
def test_extract_active_names_uses_compact_distinct_names(session: Session):
p = Product(
id=uuid.uuid4(),
name="Test",
category="serum",
brand="Test",
recommended_time="both",
leave_on=True,
actives=[
{"name": "Niacinamide", "percent": 10},
{"name": "Niacinamide", "percent": 5},
{"name": "Zinc PCA", "percent": 1},
],
product_effect_profile={},
)
names = _extract_active_names(p)
assert names == ["Niacinamide", "Zinc PCA"]
def test_additional_tool_handlers_return_product_payloads(session: Session):
p = Product(
id=uuid.uuid4(),
name="Detail Product",
category="serum",
brand="Test",
recommended_time="both",
leave_on=True,
usage_notes="Apply morning and evening.",
actives=[{"name": "Niacinamide", "percent": 5, "functions": ["niacinamide"]}],
incompatible_with=[{"target": "Retinol", "scope": "same_step"}],
context_rules={"safe_after_shaving": True},
product_effect_profile={},
)
ids_payload = {"product_ids": [str(p.id)]}
actives_out = _build_actives_tool_handler([p])(ids_payload)
assert actives_out["products"][0]["actives"][0]["name"] == "Niacinamide"
notes_out = _build_usage_notes_tool_handler([p])(ids_payload)
assert notes_out["products"][0]["usage_notes"] == "Apply morning and evening."
safety_out = _build_safety_rules_tool_handler([p])(ids_payload)
assert "incompatible_with" in safety_out["products"][0]
assert "context_rules" in safety_out["products"][0]

733
backend/uv.lock generated
View file

@ -2,18 +2,6 @@ version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "aiofile"
version = "3.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "caio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" },
]
[[package]]
name = "alembic"
version = "1.18.4"
@ -59,36 +47,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "attrs"
version = "25.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
[[package]]
name = "authlib"
version = "1.6.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6b/6c/c88eac87468c607f88bc24df1f3b31445ee6fc9ba123b09e666adf687cd9/authlib-1.6.8.tar.gz", hash = "sha256:41ae180a17cf672bc784e4a518e5c82687f1fe1e98b0cafaeda80c8e4ab2d1cb", size = 165074, upload-time = "2026-02-14T04:02:17.941Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/73/f7084bf12755113cd535ae586782ff3a6e710bfbe6a0d13d1c2f81ffbbfa/authlib-1.6.8-py2.py3-none-any.whl", hash = "sha256:97286fd7a15e6cfefc32771c8ef9c54f0ed58028f1322de6a2a7c969c3817888", size = 244116, upload-time = "2026-02-14T04:02:15.579Z" },
]
[[package]]
name = "beartype"
version = "0.22.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" },
]
[[package]]
name = "black"
version = "26.1.0"
@ -121,30 +79,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" },
]
[[package]]
name = "cachetools"
version = "7.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/07/56595285564e90777d758ebd383d6b0b971b87729bbe2184a849932a3736/cachetools-7.0.1.tar.gz", hash = "sha256:e31e579d2c5b6e2944177a0397150d312888ddf4e16e12f1016068f0c03b8341", size = 36126, upload-time = "2026-02-10T22:24:05.03Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/9e/5faefbf9db1db466d633735faceda1f94aa99ce506ac450d232536266b32/cachetools-7.0.1-py3-none-any.whl", hash = "sha256:8f086515c254d5664ae2146d14fc7f65c9a4bce75152eb247e5a9c5e6d7b2ecf", size = 13484, upload-time = "2026-02-10T22:24:03.741Z" },
]
[[package]]
name = "caio"
version = "0.9.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" },
{ url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" },
{ url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" },
{ url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" },
{ url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" },
{ url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" },
{ url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
@ -289,6 +223,90 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.13.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" },
{ url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" },
{ url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" },
{ url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" },
{ url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" },
{ url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" },
{ url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" },
{ url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" },
{ url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" },
{ url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" },
{ url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" },
{ url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" },
{ url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" },
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
]
[[package]]
name = "cryptography"
version = "46.0.5"
@ -342,21 +360,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
]
[[package]]
name = "cyclopts"
version = "4.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "docstring-parser" },
{ name = "rich" },
{ name = "rich-rst" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/5c/88a4068c660a096bbe87efc5b7c190080c9e86919c36ec5f092cb08d852f/cyclopts-4.6.0.tar.gz", hash = "sha256:483c4704b953ea6da742e8de15972f405d2e748d19a848a4d61595e8e5360ee5", size = 162724, upload-time = "2026-02-23T15:44:49.286Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/eb/1e8337755a70dc7d7ff10a73dc8f20e9352c9ad6c2256ed863ac95cd3539/cyclopts-4.6.0-py3-none-any.whl", hash = "sha256:0a891cb55bfd79a3cdce024db8987b33316aba11071e5258c21ac12a640ba9f2", size = 200518, upload-time = "2026-02-23T15:44:47.854Z" },
]
[[package]]
name = "distro"
version = "1.9.0"
@ -366,58 +369,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "docstring-parser"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
]
[[package]]
name = "docutils"
version = "0.22.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" },
]
[[package]]
name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "fastapi"
version = "0.132.0"
@ -434,37 +385,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/de/6171c3363bbc5e01686e200e0880647c9270daa476d91030435cf14d32f5/fastapi-0.132.0-py3-none-any.whl", hash = "sha256:3c487d5afce196fa8ea509ae1531e96ccd5cdd2fd6eae78b73e2c20fba706689", size = 104652, upload-time = "2026-02-23T17:56:20.836Z" },
]
[[package]]
name = "fastmcp"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "authlib" },
{ name = "cyclopts" },
{ name = "exceptiongroup" },
{ name = "httpx" },
{ name = "jsonref" },
{ name = "jsonschema-path" },
{ name = "mcp" },
{ name = "openapi-pydantic" },
{ name = "opentelemetry-api" },
{ name = "packaging" },
{ name = "platformdirs" },
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
{ name = "pydantic", extra = ["email"] },
{ name = "pyperclip" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "rich" },
{ name = "uvicorn" },
{ name = "watchfiles" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/11/6b/1a7ec89727797fb07ec0928e9070fa2f45e7b35718e1fe01633a34c35e45/fastmcp-3.0.2.tar.gz", hash = "sha256:6bd73b4a3bab773ee6932df5249dcbcd78ed18365ed0aeeb97bb42702a7198d7", size = 17239351, upload-time = "2026-02-22T16:32:28.843Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/5a/f410a9015cfde71adf646dab4ef2feae49f92f34f6050fcfb265eb126b30/fastmcp-3.0.2-py3-none-any.whl", hash = "sha256:f513d80d4b30b54749fe8950116b1aab843f3c293f5cb971fc8665cb48dbb028", size = 606268, upload-time = "2026-02-22T16:32:30.992Z" },
]
[[package]]
name = "google-auth"
version = "2.48.0"
@ -610,15 +530,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "httpx-sse"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
]
[[package]]
name = "idna"
version = "3.11"
@ -628,18 +539,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "importlib-metadata"
version = "8.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@ -656,7 +555,6 @@ source = { virtual = "." }
dependencies = [
{ name = "alembic" },
{ name = "fastapi" },
{ name = "fastmcp" },
{ name = "google-genai" },
{ name = "psycopg" },
{ name = "python-dotenv" },
@ -671,6 +569,7 @@ dev = [
{ name = "httpx" },
{ name = "isort" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
{ name = "ty" },
]
@ -679,7 +578,6 @@ dev = [
requires-dist = [
{ name = "alembic", specifier = ">=1.14" },
{ name = "fastapi", specifier = ">=0.132.0" },
{ name = "fastmcp", specifier = ">=2.0" },
{ name = "google-genai", specifier = ">=1.65.0" },
{ name = "psycopg", specifier = ">=3.3.3" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
@ -694,6 +592,7 @@ dev = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "isort", specifier = ">=8.0.0" },
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest-cov", specifier = ">=6.0.0" },
{ name = "ruff", specifier = ">=0.15.2" },
{ name = "ty", specifier = ">=0.0.18" },
]
@ -707,115 +606,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/74/ea/cf3aad99dd12c026e2d6835d559efb6fc50ccfd5b46d42d5fec2608b116a/isort-8.0.0-py3-none-any.whl", hash = "sha256:184916a933041c7cf718787f7e52064f3c06272aff69a5cb4dc46497bd8911d9", size = 89715, upload-time = "2026-02-19T16:31:57.745Z" },
]
[[package]]
name = "jaraco-classes"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
]
[[package]]
name = "jaraco-context"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850, upload-time = "2026-01-13T02:53:53.847Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065, upload-time = "2026-01-13T02:53:53.031Z" },
]
[[package]]
name = "jaraco-functools"
version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" },
]
[[package]]
name = "jeepney"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
]
[[package]]
name = "jsonref"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
]
[[package]]
name = "jsonschema-path"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pathable" },
{ name = "pyyaml" },
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/b4/41315eea8301a5353bca3578792767135b8edbc081b20618a3f0b4d78307/jsonschema_path-0.4.4.tar.gz", hash = "sha256:4c55842890fc384262a59fb63a25c86cc0e2b059e929c18b851c1d19ef612026", size = 14923, upload-time = "2026-02-28T11:58:26.289Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/36/cb2cd6543776d02875de600f12fcd81611daf359544c9ad2abb12d3122a5/jsonschema_path-0.4.4-py3-none-any.whl", hash = "sha256:669bb69cb92cd4c54acf38ee2ff7c3d9ab6b69991698f7a2f17d2bb0e5c9c394", size = 19226, upload-time = "2026-02-28T11:58:25.143Z" },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "keyring"
version = "25.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jaraco-classes" },
{ name = "jaraco-context" },
{ name = "jaraco-functools" },
{ name = "jeepney", marker = "sys_platform == 'linux'" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "secretstorage", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
]
[[package]]
name = "mako"
version = "1.3.10"
@ -828,18 +618,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
@ -903,49 +681,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mcp"
version = "1.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "jsonschema" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "more-itertools"
version = "10.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
@ -955,31 +690,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "openapi-pydantic"
version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" },
]
[[package]]
name = "packaging"
version = "26.0"
@ -989,15 +699,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pathable"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", size = 16655, upload-time = "2026-02-20T08:47:00.748Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" },
]
[[package]]
name = "pathspec"
version = "1.0.4"
@ -1038,31 +739,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
]
[[package]]
name = "py-key-value-aio"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beartype" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" },
]
[package.optional-dependencies]
filetree = [
{ name = "aiofile" },
{ name = "anyio" },
]
keyring = [
{ name = "keyring" },
]
memory = [
{ name = "cachetools" },
]
[[package]]
name = "pyasn1"
version = "0.6.2"
@ -1108,11 +784,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[package.optional-dependencies]
email = [
{ name = "email-validator" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
@ -1184,20 +855,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
@ -1207,29 +864,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pyjwt"
version = "2.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]]
name = "pyperclip"
version = "1.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
@ -1246,6 +880,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
@ -1293,31 +941,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" },
]
[[package]]
name = "pywin32"
version = "311"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
{ url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
{ url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
{ url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
{ url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
{ url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
{ url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
@ -1364,20 +987,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "referencing"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
@ -1393,113 +1002,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "rich-rst"
version = "1.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "docutils" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" },
]
[[package]]
name = "rpds-py"
version = "0.30.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
{ url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
{ url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
{ url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
{ url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
{ url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
{ url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
{ url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
{ url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
{ url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
{ url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
{ url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
{ url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
{ url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
{ url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
{ url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
{ url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
{ url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
{ url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
{ url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
{ url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
{ url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
{ url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
{ url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
{ url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
{ url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
{ url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
{ url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
{ url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
{ url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
{ url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
{ url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
{ url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
{ url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
{ url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
{ url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
{ url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
{ url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
{ url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
{ url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
{ url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
{ url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
{ url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
{ url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
{ url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
{ url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
{ url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
{ url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
{ url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
{ url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
{ url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
{ url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
{ url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
{ url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
{ url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
{ url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
{ url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
{ url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
{ url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
{ url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
{ url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
{ url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
{ url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
{ url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
{ url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
]
[[package]]
name = "rsa"
version = "4.9.1"
@ -1537,19 +1039,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
]
[[package]]
name = "secretstorage"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "jeepney" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
@ -1614,19 +1103,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/e1/7c8d18e737433f3b5bbe27b56a9072a9fcb36342b48f1bef34b6da1d61f2/sqlmodel-0.0.37-py3-none-any.whl", hash = "sha256:2137a4045ef3fd66a917a7717ada959a1ceb3630d95e1f6aaab39dd2c0aef278", size = 27224, upload-time = "2026-02-21T16:39:47.781Z" },
]
[[package]]
name = "sse-starlette"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/c3695c2d2d4ef70072c3a06992850498b01c6bc9be531950813716b426fa/sse_starlette-3.3.2.tar.gz", hash = "sha256:678fca55a1945c734d8472a6cad186a55ab02840b4f6786f5ee8770970579dcd", size = 32326, upload-time = "2026-02-28T11:24:34.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl", hash = "sha256:5c3ea3dad425c601236726af2f27689b74494643f57017cafcb6f8c9acfbb862", size = 14270, upload-time = "2026-02-28T11:24:32.984Z" },
]
[[package]]
name = "starlette"
version = "0.52.1"
@ -1882,12 +1358,3 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]
[[package]]
name = "zipp"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
]

63
deploy.sh Executable file
View file

@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Usage: ./deploy.sh [frontend|backend|all]
# default: all
#
# SSH config (~/.ssh/config) — recommended:
# Host innercontext
# HostName <IP_LXC>
# User innercontext
#
# The innercontext user needs passwordless sudo for systemctl only:
# /etc/sudoers.d/innercontext-deploy:
# innercontext ALL=(root) NOPASSWD: /usr/bin/systemctl restart innercontext, /usr/bin/systemctl restart innercontext-node, /usr/bin/systemctl is-active innercontext, /usr/bin/systemctl is-active innercontext-node
set -euo pipefail
SERVER="${DEPLOY_SERVER:-innercontext}" # ssh host alias or user@host
REMOTE="/opt/innercontext"
SCOPE="${1:-all}"
# ── Frontend ───────────────────────────────────────────────────────────────
deploy_frontend() {
echo "==> [frontend] Building locally..."
(cd frontend && pnpm run build)
echo "==> [frontend] Uploading build/ and package files..."
rsync -az --delete frontend/build/ "$SERVER:$REMOTE/frontend/build/"
rsync -az frontend/package.json frontend/pnpm-lock.yaml "$SERVER:$REMOTE/frontend/"
echo "==> [frontend] Installing production dependencies on server..."
ssh "$SERVER" "cd $REMOTE/frontend && pnpm install --prod --frozen-lockfile --ignore-scripts"
echo "==> [frontend] Restarting service..."
ssh "$SERVER" "sudo systemctl restart innercontext-node && echo OK"
}
# ── Backend ────────────────────────────────────────────────────────────────
deploy_backend() {
echo "==> [backend] Uploading source..."
rsync -az --delete \
--exclude='.venv/' \
--exclude='__pycache__/' \
--exclude='*.pyc' \
--exclude='.env' \
backend/ "$SERVER:$REMOTE/backend/"
echo "==> [backend] Syncing dependencies..."
ssh "$SERVER" "cd $REMOTE/backend && uv sync --frozen --no-dev --no-editable"
echo "==> [backend] Restarting service (alembic runs on start)..."
ssh "$SERVER" "sudo systemctl restart innercontext && echo OK"
}
# ── Dispatch ───────────────────────────────────────────────────────────────
case "$SCOPE" in
frontend) deploy_frontend ;;
backend) deploy_backend ;;
all) deploy_frontend; deploy_backend ;;
*)
echo "Usage: $0 [frontend|backend|all]"
exit 1
;;
esac
echo "==> Done."

View file

@ -7,13 +7,16 @@ Reverse proxy (existing) innercontext LXC (new, Debian 13)
┌──────────────────────┐ ┌────────────────────────────────────┐
│ reverse proxy │────────────▶│ nginx :80 │
│ innercontext.lan → * │ │ /api/* → uvicorn :8000/* │
└──────────────────────┘ │ /mcp/* → uvicorn :8000/mcp/* │
│ /* → SvelteKit Node :3000 │
└──────────────────────┘ │ /* → SvelteKit Node :3000 │
└────────────────────────────────────┘
│ │
FastAPI + MCP SvelteKit Node
FastAPI SvelteKit Node
```
> **Frontend is never built on the server.** The `vite build` + `adapter-node`
> esbuild step is CPU/RAM-intensive and will hang on a small LXC. Build locally,
> deploy the `build/` artifact via `deploy.sh`.
## 1. Prerequisites
- Proxmox VE host with an existing PostgreSQL LXC and a reverse proxy
@ -53,7 +56,7 @@ pct enter 200 # or SSH into the container
```bash
apt update && apt upgrade -y
apt install -y git nginx curl ca-certificates gnupg lsb-release libpq5
apt install -y git nginx curl ca-certificates gnupg lsb-release libpq5 rsync
```
### Python 3.12+ + uv
@ -67,6 +70,11 @@ Installing to `/usr/local/bin` makes `uv` available system-wide (required for `s
### Node.js 24 LTS + pnpm
The server needs Node.js to **run** the pre-built frontend bundle, and pnpm to
**install production runtime dependencies** (`clsx`, `bits-ui`, etc. —
`adapter-node` bundles the SvelteKit framework but leaves these external).
The frontend is never **built** on the server.
```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
. "$HOME/.nvm/nvm.sh"
@ -75,16 +83,14 @@ nvm install 24
Copy `node` to `/usr/local/bin` so it is accessible system-wide
(required for `sudo -u innercontext` and for systemd).
Symlinking into `/root/.nvm/` won't work — other users can't traverse `/root/`.
Use `--remove-destination` to replace any existing symlink with a real file:
```bash
cp --remove-destination "$(nvm which current)" /usr/local/bin/node
```
Install pnpm as a standalone binary from GitHub releases — self-contained,
no wrapper scripts, works system-wide. Do **not** use `corepack enable pnpm`
(the shim requires its nvm directory structure and breaks when copied/linked):
Install pnpm as a standalone binary — self-contained, no wrapper scripts,
works system-wide:
```bash
curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linux-x64" \
@ -194,11 +200,10 @@ systemctl status innercontext
---
## 7. Frontend build and setup
## 7. Frontend setup
```bash
cd /opt/innercontext/frontend
```
The frontend is **built locally and uploaded** via `deploy.sh` — never built on the server.
This section only covers the one-time server-side configuration.
### Create `.env.production`
@ -211,25 +216,24 @@ chmod 600 /opt/innercontext/frontend/.env.production
chown innercontext:innercontext /opt/innercontext/frontend/.env.production
```
### Install dependencies and build
### Grant `innercontext` passwordless sudo for service restarts
```bash
sudo -u innercontext bash -c '
cd /opt/innercontext/frontend
pnpm install
PUBLIC_API_BASE=http://innercontext.lan/api pnpm build
'
cat > /etc/sudoers.d/innercontext-deploy << 'EOF'
innercontext ALL=(root) NOPASSWD: \
/usr/bin/systemctl restart innercontext, \
/usr/bin/systemctl restart innercontext-node
EOF
chmod 440 /etc/sudoers.d/innercontext-deploy
```
The production build lands in `/opt/innercontext/frontend/build/`.
### Install systemd service
```bash
cp /opt/innercontext/systemd/innercontext-node.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now innercontext-node
systemctl status innercontext-node
systemctl enable innercontext-node
# Do NOT start yet — build/ is empty until the first deploy.sh run
```
---
@ -276,44 +280,64 @@ Reload your reverse proxy after applying the change.
---
## 10. Verification
## 10. First deploy from local machine
All subsequent deploys (including the first one) use `deploy.sh` from your local machine.
### SSH config
Add to `~/.ssh/config` on your local machine:
```
Host innercontext
HostName <innercontext-lxc-ip>
User innercontext
```
Make sure your SSH public key is in `/home/innercontext/.ssh/authorized_keys` on the server.
### Run the first deploy
```bash
# From the repo root on your local machine:
./deploy.sh
```
This will:
1. Build the frontend locally (`pnpm run build`)
2. Upload `frontend/build/` to the server via rsync
3. Restart `innercontext-node`
4. Upload `backend/` source to the server
5. Run `uv sync --frozen` on the server
6. Restart `innercontext` (runs alembic migrations on start)
---
## 11. Verification
```bash
# From any machine on the LAN:
curl http://innercontext.lan/api/health-check # {"status":"ok"}
curl http://innercontext.lan/api/products # []
curl http://innercontext.lan/ # SvelteKit HTML shell
curl -N http://innercontext.lan/mcp/mcp # MCP StreamableHTTP endpoint
```
The web UI should be accessible at `http://innercontext.lan`.
---
## 11. Updating the application
## 12. Updating the application
```bash
cd /opt/innercontext
git pull
# Sync backend dependencies if pyproject.toml changed:
cd backend && sudo -u innercontext uv sync && cd ..
# Apply any new DB migrations (runs automatically via ExecStartPre, but safe to run manually first):
sudo -u innercontext bash -c 'cd /opt/innercontext/backend && uv run alembic upgrade head'
# Rebuild frontend:
cd frontend && sudo -u innercontext bash -c '
pnpm install
PUBLIC_API_BASE=http://innercontext.lan/api pnpm build
'
systemctl restart innercontext innercontext-node
# From the repo root on your local machine:
./deploy.sh # full deploy (frontend + backend)
./deploy.sh frontend # frontend only
./deploy.sh backend # backend only
```
---
## 12. Troubleshooting
## 13. Troubleshooting
### 502 Bad Gateway on `/api/*`
@ -328,17 +352,7 @@ journalctl -u innercontext -n 50
```bash
systemctl status innercontext-node
journalctl -u innercontext-node -n 50
# Verify /opt/innercontext/frontend/build/index.js exists (pnpm build ran successfully)
```
### MCP endpoint not responding
```bash
# MCP uses SSE — disable buffering is already in nginx config
# Verify the backend started successfully:
curl http://127.0.0.1:8000/health-check
# Check FastAPI logs:
journalctl -u innercontext -n 50
# Verify /opt/innercontext/frontend/build/index.js exists (deploy.sh ran successfully)
```
### Database connection refused

10
frontend/.mcp.json Normal file
View file

@ -0,0 +1,10 @@
{
"mcpServers": {
"svelte": {
"type": "stdio",
"command": "npx",
"env": {},
"args": ["-y", "@sveltejs/mcp"]
}
}
}

8
frontend/.prettierignore Normal file
View file

@ -0,0 +1,8 @@
node_modules
.svelte-kit
paraglide
build
dist
.env
.env.*
!.env.example

9
frontend/.prettierrc Normal file
View file

@ -0,0 +1,9 @@
{
"plugins": ["svelte"],
"overrides": [
{
"files": ["*.svelte"],
"parser": "svelte-eslint-parser"
}
]
}

View file

@ -24,8 +24,8 @@ The backend must be running at `http://localhost:8000`. See `../backend/` for se
## Environment variables
| Variable | Description | Default |
|---|---|---|
| Variable | Description | Default |
| ----------------- | ------------------------------- | ----------------------- |
| `PUBLIC_API_BASE` | Base URL of the FastAPI backend | `http://localhost:8000` |
Set `PUBLIC_API_BASE` at **build time** for production:
@ -51,24 +51,24 @@ Or use the provided systemd service: `../systemd/innercontext-node.service`.
## Routes
| Route | Description |
|---|---|
| `/` | Dashboard |
| `/products` | Product list |
| `/products/new` | Add product |
| `/products/[id]` | Product detail / edit |
| `/routines` | Routine list |
| `/routines/new` | Create routine |
| `/routines/[id]` | Routine detail |
| `/health/medications` | Medications |
| `/health/lab-results` | Lab results |
| `/skin` | Skin condition snapshots |
| Route | Description |
| --------------------- | ------------------------ |
| `/` | Dashboard |
| `/products` | Product list |
| `/products/new` | Add product |
| `/products/[id]` | Product detail / edit |
| `/routines` | Routine list |
| `/routines/new` | Create routine |
| `/routines/[id]` | Routine detail |
| `/health/medications` | Medications |
| `/health/lab-results` | Lab results |
| `/skin` | Skin condition snapshots |
## Key files
| File | Purpose |
|---|---|
| `src/lib/api.ts` | API client (typed fetch wrappers) |
| `src/lib/types.ts` | Shared TypeScript types |
| `src/app.css` | Tailwind v4 theme + global styles |
| `svelte.config.js` | SvelteKit config (adapter-node) |
| File | Purpose |
| ------------------ | --------------------------------- |
| `src/lib/api.ts` | API client (typed fetch wrappers) |
| `src/lib/types.ts` | Shared TypeScript types |
| `src/app.css` | Tailwind v4 theme + global styles |
| `svelte.config.js` | SvelteKit config (adapter-node) |

View file

@ -1,17 +1,17 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": {
"config": "",
"css": "src/app.css",
"baseColor": "zinc"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"registry": "https://shadcn-svelte.com/registry"
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": {
"config": "",
"css": "src/app.css",
"baseColor": "zinc"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"registry": "https://shadcn-svelte.com/registry"
}

47
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,47 @@
import js from "@eslint/js";
import svelte from "eslint-plugin-svelte";
import ts from "typescript-eslint";
import globals from "globals";
export default [
{
ignores: [
".svelte-kit",
"node_modules",
"build",
"dist",
"**/paraglide/**",
"**/lib/paraglide/**",
],
},
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs["flat/recommended"],
{
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
},
},
rules: {
"svelte/no-at-html-tags": "off",
"svelte/require-each-key": "off",
// TODO: Set ignoreGoto to false when https://github.com/sveltejs/eslint-plugin-svelte/issues/1327 is fixed
// The rule doesn't detect resolve() when used with string concatenation for query params
"svelte/no-navigation-without-resolve": [
"error",
{ ignoreLinks: true, ignoreGoto: true },
],
},
},
{
files: ["**/*.svelte"],
languageOptions: {
parserOptions: {
parser: ts.parser,
},
},
},
];

View file

@ -1,437 +1,522 @@
{
"nav_dashboard": "Dashboard",
"nav_products": "Products",
"nav_routines": "Routines",
"nav_grooming": "Grooming",
"nav_medications": "Medications",
"nav_labResults": "Lab Results",
"nav_skin": "Skin",
"nav_appName": "innercontext",
"nav_appSubtitle": "personal health & skincare",
"nav_dashboard": "Dashboard",
"nav_products": "Products",
"nav_routines": "Routines",
"nav_grooming": "Grooming",
"nav_medications": "Medications",
"nav_labResults": "Lab Results",
"nav_skin": "Skin",
"nav_appName": "innercontext",
"nav_appSubtitle": "personal health & skincare",
"common_save": "Save",
"common_cancel": "Cancel",
"common_add": "Add",
"common_edit": "Edit",
"common_delete": "Delete",
"common_saved": "Saved.",
"common_select": "Select",
"common_unknown": "Unknown",
"common_yes": "Yes",
"common_no": "No",
"common_unknown_value": "Unknown",
"common_optional_notes": "optional",
"common_steps": "steps",
"common_save": "Save",
"common_cancel": "Cancel",
"common_add": "Add",
"common_edit": "Edit",
"common_delete": "Delete",
"common_saved": "Saved.",
"common_select": "Select",
"common_unknown": "Unknown",
"common_yes": "Yes",
"common_no": "No",
"common_unknown_value": "Unknown",
"common_optional_notes": "optional",
"common_steps": "steps",
"dashboard_title": "Dashboard",
"dashboard_subtitle": "Your recent health & skincare overview",
"dashboard_latestSnapshot": "Latest Skin Snapshot",
"dashboard_recentRoutines": "Recent Routines",
"dashboard_noSnapshots": "No skin snapshots yet.",
"dashboard_noRoutines": "No routines in the past 2 weeks.",
"dashboard_title": "Dashboard",
"dashboard_subtitle": "Your recent health & skincare overview",
"dashboard_latestSnapshot": "Latest Skin Snapshot",
"dashboard_recentRoutines": "Recent Routines",
"dashboard_noSnapshots": "No skin snapshots yet.",
"dashboard_noRoutines": "No routines in the past 2 weeks.",
"products_title": "Products",
"products_count": "{count} products",
"products_addNew": "+ Add product",
"products_noProducts": "No products found.",
"products_filterAll": "All",
"products_filterOwned": "Owned",
"products_filterUnowned": "Not owned",
"products_colName": "Name",
"products_colBrand": "Brand",
"products_colTargets": "Targets",
"products_colTime": "Time",
"products_newTitle": "New Product",
"products_backToList": "← Products",
"products_createProduct": "Create product",
"products_saveChanges": "Save changes",
"products_deleteProduct": "Delete product",
"products_confirmDelete": "Delete this product?",
"products_noInventory": "No inventory packages.",
"products_title": "Products",
"products_count": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} product",
"countPlural=*": "{count} products"
}
}
],
"products_addNew": "+ Add product",
"products_suggest": "Suggest",
"products_suggestTitle": "Shopping suggestions",
"products_suggestSubtitle": "What to buy?",
"products_suggestDescription": "Based on your skin condition and products you own, I'll suggest product types that could complement your routine.",
"products_suggestGenerating": "Analyzing...",
"products_suggestBtn": "Generate suggestions",
"products_suggestResults": "Suggestions",
"products_suggestTime": "Time",
"products_suggestFrequency": "Frequency",
"products_suggestRegenerate": "Regenerate",
"products_suggestNoResults": "No suggestions.",
"products_noProducts": "No products found.",
"products_filterAll": "All",
"products_filterOwned": "Owned",
"products_filterUnowned": "Not owned",
"products_colName": "Name",
"products_colBrand": "Brand",
"products_colTargets": "Targets",
"products_colTime": "Time",
"products_newTitle": "New Product",
"products_backToList": "← Products",
"products_createProduct": "Create product",
"products_saveChanges": "Save changes",
"products_deleteProduct": "Delete product",
"products_confirmDelete": "Delete this product?",
"products_noInventory": "No inventory packages.",
"inventory_title": "Inventory packages ({count})",
"inventory_addPackage": "+ Add package",
"inventory_packageAdded": "Package added.",
"inventory_packageUpdated": "Package updated.",
"inventory_packageDeleted": "Package deleted.",
"inventory_alreadyOpened": "Already opened",
"inventory_openedDate": "Opened date",
"inventory_finishedDate": "Finished date",
"inventory_expiryDate": "Expiry date",
"inventory_currentWeight": "Current weight (g)",
"inventory_lastWeighed": "Last weighed",
"inventory_notes": "Notes",
"inventory_badgeOpen": "Open",
"inventory_badgeSealed": "Sealed",
"inventory_badgeFinished": "Finished",
"inventory_exp": "Exp:",
"inventory_opened": "Opened:",
"inventory_finished": "Finished:",
"inventory_remaining": "g remaining",
"inventory_weighed": "Weighed:",
"inventory_confirmDelete": "Delete this package?",
"inventory_title": "Inventory packages ({count})",
"inventory_addPackage": "+ Add package",
"inventory_packageAdded": "Package added.",
"inventory_packageUpdated": "Package updated.",
"inventory_packageDeleted": "Package deleted.",
"inventory_alreadyOpened": "Already opened",
"inventory_openedDate": "Opened date",
"inventory_finishedDate": "Finished date",
"inventory_expiryDate": "Expiry date",
"inventory_currentWeight": "Current weight (g)",
"inventory_lastWeighed": "Last weighed",
"inventory_notes": "Notes",
"inventory_badgeOpen": "Open",
"inventory_badgeSealed": "Sealed",
"inventory_badgeFinished": "Finished",
"inventory_exp": "Exp:",
"inventory_opened": "Opened:",
"inventory_finished": "Finished:",
"inventory_remaining": "g remaining",
"inventory_weighed": "Weighed:",
"inventory_confirmDelete": "Delete this package?",
"routines_title": "Routines",
"routines_count": "{count} routines (last 30 days)",
"routines_suggestAI": "Suggest AI routine",
"routines_addNew": "+ New routine",
"routines_noRoutines": "No routines found.",
"routines_newTitle": "New Routine",
"routines_backToList": "← Routines",
"routines_detailsTitle": "Routine details",
"routines_date": "Date *",
"routines_amOrPm": "AM or PM *",
"routines_notes": "Notes",
"routines_notesPlaceholder": "Optional notes",
"routines_createRoutine": "Create routine",
"routines_deleteRoutine": "Delete routine",
"routines_confirmDelete": "Delete this routine?",
"routines_steps": "Steps ({count})",
"routines_addStep": "+ Add step",
"routines_addStepTitle": "Add step",
"routines_product": "Product",
"routines_selectProduct": "Select product",
"routines_dose": "Dose",
"routines_dosePlaceholder": "e.g. 2 pumps",
"routines_region": "Region",
"routines_regionPlaceholder": "e.g. face",
"routines_addStepBtn": "Add step",
"routines_unknownStep": "Unknown step",
"routines_noSteps": "No steps yet.",
"routines_title": "Routines",
"routines_count": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} routine (last 30 days)",
"countPlural=*": "{count} routines (last 30 days)"
}
}
],
"routines_suggestAI": "Suggest AI routine",
"routines_addNew": "+ New routine",
"routines_noRoutines": "No routines found.",
"routines_newTitle": "New Routine",
"routines_backToList": "← Routines",
"routines_detailsTitle": "Routine details",
"routines_date": "Date *",
"routines_amOrPm": "AM or PM *",
"routines_notes": "Notes",
"routines_notesPlaceholder": "Optional notes",
"routines_createRoutine": "Create routine",
"routines_deleteRoutine": "Delete routine",
"routines_confirmDelete": "Delete this routine?",
"routines_steps": "Steps ({count})",
"routines_addStep": "+ Add step",
"routines_addStepTitle": "Add step",
"routines_product": "Product",
"routines_selectProduct": "Select product",
"routines_dose": "Dose",
"routines_dosePlaceholder": "e.g. 2 pumps",
"routines_region": "Region",
"routines_regionPlaceholder": "e.g. face",
"routines_addStepBtn": "Add step",
"routines_unknownStep": "Unknown step",
"routines_noSteps": "No steps yet.",
"grooming_title": "Grooming Schedule",
"grooming_backToRoutines": "← Routines",
"grooming_addEntry": "+ Add entry",
"grooming_entryAdded": "Entry added.",
"grooming_entryUpdated": "Entry updated.",
"grooming_entryDeleted": "Entry deleted.",
"grooming_dayOfWeek": "Day of week",
"grooming_action": "Action",
"grooming_notesOptional": "Notes (optional)",
"grooming_notesPlaceholder": "e.g. every 2 weeks",
"grooming_noEntries": "No entries yet. Click \"+ Add entry\" to get started.",
"grooming_confirmDelete": "Delete this entry?",
"grooming_actionShavingRazor": "Razor shaving",
"grooming_actionShavingOneblade": "OneBlade shaving",
"grooming_actionDermarolling": "Dermarolling",
"grooming_dayMonday": "Monday",
"grooming_dayTuesday": "Tuesday",
"grooming_dayWednesday": "Wednesday",
"grooming_dayThursday": "Thursday",
"grooming_dayFriday": "Friday",
"grooming_daySaturday": "Saturday",
"grooming_daySunday": "Sunday",
"grooming_title": "Grooming Schedule",
"grooming_backToRoutines": "← Routines",
"grooming_addEntry": "+ Add entry",
"grooming_entryAdded": "Entry added.",
"grooming_entryUpdated": "Entry updated.",
"grooming_entryDeleted": "Entry deleted.",
"grooming_dayOfWeek": "Day of week",
"grooming_action": "Action",
"grooming_notesOptional": "Notes (optional)",
"grooming_notesPlaceholder": "e.g. every 2 weeks",
"grooming_noEntries": "No entries yet. Click \"+ Add entry\" to get started.",
"grooming_confirmDelete": "Delete this entry?",
"grooming_actionShavingRazor": "Razor shaving",
"grooming_actionShavingOneblade": "OneBlade shaving",
"grooming_actionDermarolling": "Dermarolling",
"grooming_dayMonday": "Monday",
"grooming_dayTuesday": "Tuesday",
"grooming_dayWednesday": "Wednesday",
"grooming_dayThursday": "Thursday",
"grooming_dayFriday": "Friday",
"grooming_daySaturday": "Saturday",
"grooming_daySunday": "Sunday",
"suggest_title": "AI Routine Suggestion",
"suggest_backToRoutines": "← Routines",
"suggest_singleTab": "Single routine",
"suggest_batchTab": "Batch / Vacation",
"suggest_singleParams": "Parameters",
"suggest_date": "Date",
"suggest_timeOfDay": "Time of day",
"suggest_contextLabel": "Additional context for AI",
"suggest_contextOptional": "(optional)",
"suggest_contextPlaceholder": "e.g. party night, focusing on hydration...",
"suggest_generateBtn": "Generate suggestion",
"suggest_generating": "Generating…",
"suggest_proposalTitle": "Suggestion",
"suggest_saveRoutine": "Save routine",
"suggest_saving": "Saving…",
"suggest_regenerate": "Regenerate",
"suggest_batchRange": "Date range",
"suggest_fromDate": "From",
"suggest_toDate": "To (max 14 days)",
"suggest_batchContextLabel": "Context / trip purpose",
"suggest_batchContextPlaceholder": "e.g. sunny trip to Italy, active mountain vacation...",
"suggest_generatePlan": "Generate plan",
"suggest_generatingPlan": "Generating plan…",
"suggest_planTitle": "Plan ({count} days)",
"suggest_saveAllRoutines": "Save all routines",
"suggest_amSteps": "steps",
"suggest_pmSteps": "steps",
"suggest_noAmSteps": "No AM steps.",
"suggest_noPmSteps": "No PM steps.",
"suggest_errorDefault": "Error generating suggestion.",
"suggest_errorBatch": "Error generating plan.",
"suggest_errorSave": "Error saving.",
"suggest_amMorning": "AM (morning)",
"suggest_pmEvening": "PM (evening)",
"suggest_title": "AI Routine Suggestion",
"suggest_backToRoutines": "← Routines",
"suggest_singleTab": "Single routine",
"suggest_batchTab": "Batch / Vacation",
"suggest_singleParams": "Parameters",
"suggest_date": "Date",
"suggest_timeOfDay": "Time of day",
"suggest_contextLabel": "Additional context for AI",
"suggest_contextOptional": "(optional)",
"suggest_contextPlaceholder": "e.g. party night, focusing on hydration...",
"suggest_leavingHomeLabel": "Going outside today",
"suggest_leavingHomeHint": "Affects SPF selection — checked: SPF50+, unchecked: SPF30.",
"suggest_minoxidilToggleLabel": "Prioritize beard/mustache density (minoxidil)",
"suggest_minoxidilToggleHint": "When enabled, AI will explicitly consider minoxidil for beard/mustache areas if available.",
"suggest_generateBtn": "Generate suggestion",
"suggest_generating": "Generating…",
"suggest_proposalTitle": "Suggestion",
"suggest_saveRoutine": "Save routine",
"suggest_saving": "Saving…",
"suggest_regenerate": "Regenerate",
"suggest_batchRange": "Date range",
"suggest_fromDate": "From",
"suggest_toDate": "To (max 14 days)",
"suggest_batchContextLabel": "Context / trip purpose",
"suggest_batchContextPlaceholder": "e.g. sunny trip to Italy, active mountain vacation...",
"suggest_generatePlan": "Generate plan",
"suggest_generatingPlan": "Generating plan…",
"suggest_planTitle": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Plan ({count} day)",
"countPlural=*": "Plan ({count} days)"
}
}
],
"suggest_saveAllRoutines": "Save all routines",
"suggest_amSteps": "steps",
"suggest_pmSteps": "steps",
"suggest_noAmSteps": "No AM steps.",
"suggest_noPmSteps": "No PM steps.",
"suggest_errorDefault": "Error generating suggestion.",
"suggest_errorBatch": "Error generating plan.",
"suggest_errorSave": "Error saving.",
"suggest_amMorning": "AM (morning)",
"suggest_pmEvening": "PM (evening)",
"suggest_summaryPrimaryGoal": "Primary goal",
"suggest_summaryConfidence": "Confidence",
"suggest_summaryConstraints": "Constraints",
"suggest_stepOptionalBadge": "optional",
"medications_title": "Medications",
"medications_count": "{count} entries",
"medications_addNew": "+ Add medication",
"medications_newTitle": "New medication",
"medications_kind": "Kind",
"medications_productName": "Product name *",
"medications_productNamePlaceholder": "e.g. Vitamin D3",
"medications_activeSubstance": "Active substance",
"medications_activeSubstancePlaceholder": "e.g. cholecalciferol",
"medications_notes": "Notes",
"medications_added": "Medication added.",
"medications_usages": "{count} usages",
"medications_noMedications": "No medications recorded.",
"medications_kindPrescription": "Prescription",
"medications_kindOtc": "OTC",
"medications_kindSupplement": "Supplement",
"medications_kindHerbal": "Herbal",
"medications_kindOther": "Other",
"medications_title": "Medications",
"medications_count": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} entry",
"countPlural=*": "{count} entries"
}
}
],
"medications_addNew": "+ Add medication",
"medications_newTitle": "New medication",
"medications_kind": "Kind",
"medications_productName": "Product name *",
"medications_productNamePlaceholder": "e.g. Vitamin D3",
"medications_activeSubstance": "Active substance",
"medications_activeSubstancePlaceholder": "e.g. cholecalciferol",
"medications_notes": "Notes",
"medications_added": "Medication added.",
"medications_usages": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} usage",
"countPlural=*": "{count} usages"
}
}
],
"medications_noMedications": "No medications recorded.",
"medications_kindPrescription": "Prescription",
"medications_kindOtc": "OTC",
"medications_kindSupplement": "Supplement",
"medications_kindHerbal": "Herbal",
"medications_kindOther": "Other",
"labResults_title": "Lab Results",
"labResults_count": "{count} results",
"labResults_addNew": "+ Add result",
"labResults_newTitle": "New lab result",
"labResults_flagFilter": "Flag:",
"labResults_flagAll": "All",
"labResults_flagNone": "None",
"labResults_date": "Date *",
"labResults_loincCode": "LOINC code *",
"labResults_testName": "Test name",
"labResults_testNamePlaceholder": "e.g. Hemoglobin",
"labResults_lab": "Lab",
"labResults_labPlaceholder": "e.g. LabCorp",
"labResults_value": "Value",
"labResults_unit": "Unit",
"labResults_unitPlaceholder": "e.g. g/dL",
"labResults_flag": "Flag",
"labResults_added": "Result added.",
"labResults_colDate": "Date",
"labResults_colTest": "Test",
"labResults_colLoinc": "LOINC",
"labResults_colValue": "Value",
"labResults_colFlag": "Flag",
"labResults_colLab": "Lab",
"labResults_noResults": "No lab results found.",
"labResults_title": "Lab Results",
"labResults_count": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} result",
"countPlural=*": "{count} results"
}
}
],
"labResults_addNew": "+ Add result",
"labResults_newTitle": "New lab result",
"labResults_flagFilter": "Flag:",
"labResults_flagAll": "All",
"labResults_flagNone": "None",
"labResults_date": "Date *",
"labResults_loincCode": "LOINC code *",
"labResults_testName": "Test name",
"labResults_testNamePlaceholder": "e.g. Hemoglobin",
"labResults_lab": "Lab",
"labResults_labPlaceholder": "e.g. LabCorp",
"labResults_value": "Value",
"labResults_unit": "Unit",
"labResults_unitPlaceholder": "e.g. g/dL",
"labResults_flag": "Flag",
"labResults_added": "Result added.",
"labResults_colDate": "Date",
"labResults_colTest": "Test",
"labResults_colLoinc": "LOINC",
"labResults_colValue": "Value",
"labResults_colFlag": "Flag",
"labResults_colLab": "Lab",
"labResults_noResults": "No lab results found.",
"skin_title": "Skin Snapshots",
"skin_count": "{count} snapshots",
"skin_addNew": "+ Add snapshot",
"skin_aiAnalysisTitle": "AI analysis from photos",
"skin_aiUploadText": "Upload 13 photos of your skin. AI will pre-fill the form fields below.",
"skin_analyzePhotos": "Analyze photos",
"skin_analyzing": "Analyzing…",
"skin_newSnapshotTitle": "New skin snapshot",
"skin_date": "Date *",
"skin_overallState": "Overall state",
"skin_texture": "Texture",
"skin_skinType": "Skin type",
"skin_barrierState": "Barrier state",
"skin_hydration": "Hydration (15)",
"skin_sensitivity": "Sensitivity (15)",
"skin_sebumTzone": "Sebum T-zone (15)",
"skin_sebumCheeks": "Sebum cheeks (15)",
"skin_activeConcerns": "Active concerns (comma-separated)",
"skin_activeConcernsPlaceholder": "acne, redness, dehydration",
"skin_notes": "Notes",
"skin_addSnapshot": "Add snapshot",
"skin_snapshotAdded": "Snapshot added.",
"skin_snapshotUpdated": "Snapshot updated.",
"skin_snapshotDeleted": "Snapshot deleted.",
"skin_noSnapshots": "No skin snapshots yet.",
"skin_hydrationLabel": "Hydration",
"skin_sensitivityLabel": "Sensitivity",
"skin_barrierLabel": "Barrier",
"skin_stateExcellent": "excellent",
"skin_stateGood": "good",
"skin_stateFair": "fair",
"skin_statePoor": "poor",
"skin_textureSmooth": "smooth",
"skin_textureRough": "rough",
"skin_textureFlaky": "flaky",
"skin_textureBumpy": "bumpy",
"skin_barrierIntact": "intact",
"skin_barrierMildly": "mildly compromised",
"skin_barrierCompromised": "compromised",
"skin_typeDry": "dry",
"skin_typeOily": "oily",
"skin_typeCombination": "combination",
"skin_typeSensitive": "sensitive",
"skin_typeNormal": "normal",
"skin_typeAcneProne": "acne prone",
"skin_title": "Skin Snapshots",
"skin_count": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} snapshot",
"countPlural=*": "{count} snapshots"
}
}
],
"skin_addNew": "+ Add snapshot",
"skin_aiAnalysisTitle": "AI analysis from photos",
"skin_aiUploadText": "Upload 13 photos of your skin. AI will pre-fill the form fields below.",
"skin_analyzePhotos": "Analyze photos",
"skin_analyzing": "Analyzing…",
"skin_newSnapshotTitle": "New skin snapshot",
"skin_date": "Date *",
"skin_overallState": "Overall state",
"skin_texture": "Texture",
"skin_skinType": "Skin type",
"skin_barrierState": "Barrier state",
"skin_hydration": "Hydration (15)",
"skin_sensitivity": "Sensitivity (15)",
"skin_sebumTzone": "Sebum T-zone (15)",
"skin_sebumCheeks": "Sebum cheeks (15)",
"skin_activeConcerns": "Active concerns (comma-separated)",
"skin_activeConcernsPlaceholder": "acne, redness, dehydration",
"skin_priorities": "Priorities (comma-separated)",
"skin_prioritiesPlaceholder": "strengthen barrier, reduce redness",
"skin_prioritiesLabel": "Priorities",
"skin_notes": "Notes",
"skin_addSnapshot": "Add snapshot",
"skin_snapshotAdded": "Snapshot added.",
"skin_snapshotUpdated": "Snapshot updated.",
"skin_snapshotDeleted": "Snapshot deleted.",
"skin_noSnapshots": "No skin snapshots yet.",
"skin_hydrationLabel": "Hydration",
"skin_sensitivityLabel": "Sensitivity",
"skin_barrierLabel": "Barrier",
"skin_stateExcellent": "excellent",
"skin_stateGood": "good",
"skin_stateFair": "fair",
"skin_statePoor": "poor",
"skin_textureSmooth": "smooth",
"skin_textureRough": "rough",
"skin_textureFlaky": "flaky",
"skin_textureBumpy": "bumpy",
"skin_barrierIntact": "intact",
"skin_barrierMildly": "mildly compromised",
"skin_barrierCompromised": "compromised",
"skin_typeDry": "dry",
"skin_typeOily": "oily",
"skin_typeCombination": "combination",
"skin_typeSensitive": "sensitive",
"skin_typeNormal": "normal",
"skin_typeAcneProne": "acne prone",
"productForm_aiPrefill": "AI pre-fill",
"productForm_aiPrefillText": "Paste product description from a website, ingredient list, or other text. AI will fill in available fields — you can review and correct before saving.",
"productForm_pasteText": "Paste product description, INCI ingredients here...",
"productForm_parseWithAI": "Fill fields (AI)",
"productForm_parsing": "Processing…",
"productForm_basicInfo": "Basic info",
"productForm_name": "Name *",
"productForm_namePlaceholder": "e.g. Hydro Boost Water Gel",
"productForm_brand": "Brand *",
"productForm_brandPlaceholder": "e.g. Neutrogena",
"productForm_lineName": "Line / series",
"productForm_lineNamePlaceholder": "e.g. Hydro Boost",
"productForm_url": "URL",
"productForm_sku": "SKU",
"productForm_skuPlaceholder": "e.g. NTR-HB-50",
"productForm_barcode": "Barcode / EAN",
"productForm_barcodePlaceholder": "e.g. 3614273258975",
"productForm_classification": "Classification",
"productForm_category": "Category *",
"productForm_selectCategory": "Select category",
"productForm_time": "Time *",
"productForm_timeOptions": "AM / PM / Both",
"productForm_timeBoth": "Both",
"productForm_leaveOn": "Leave-on *",
"productForm_leaveOnYes": "Yes (leave-on)",
"productForm_leaveOnNo": "No (rinse-off)",
"productForm_texture": "Texture",
"productForm_selectTexture": "Select texture",
"productForm_absorptionSpeed": "Absorption speed",
"productForm_selectSpeed": "Select speed",
"productForm_skinProfile": "Skin profile",
"productForm_recommendedFor": "Recommended for skin types",
"productForm_targetConcerns": "Target concerns",
"productForm_contraindications": "Contraindications (one per line)",
"productForm_contraindicationsPlaceholder": "e.g. active rosacea flares",
"productForm_ingredients": "Ingredients",
"productForm_inciList": "INCI list (one ingredient per line)",
"productForm_activeIngredients": "Active ingredients",
"productForm_addActive": "+ Add active",
"productForm_noActives": "No actives added yet.",
"productForm_activeName": "Name",
"productForm_activePercent": "%",
"productForm_activeStrength": "Strength",
"productForm_activeIrritation": "Irritation",
"productForm_activeFunctions": "Functions",
"productForm_effectProfile": "Effect profile (05)",
"productForm_interactions": "Interactions",
"productForm_synergizesWith": "Synergizes with (one per line)",
"productForm_incompatibleWith": "Incompatible with",
"productForm_addIncompatibility": "+ Add incompatibility",
"productForm_noIncompatibilities": "No incompatibilities added.",
"productForm_incompTarget": "Target ingredient",
"productForm_incompScope": "Scope",
"productForm_incompReason": "Reason (optional)",
"productForm_incompReasonPlaceholder": "e.g. reduces efficacy",
"productForm_incompScopeSelect": "Select…",
"productForm_contextRules": "Context rules",
"productForm_ctxAfterShaving": "Safe after shaving",
"productForm_ctxAfterAcids": "Safe after acids",
"productForm_ctxAfterRetinoids": "Safe after retinoids",
"productForm_ctxCompromisedBarrier": "Safe with compromised barrier",
"productForm_ctxLowUvOnly": "Low UV only (evening/covered)",
"productForm_productDetails": "Product details",
"productForm_priceTier": "Price tier",
"productForm_selectTier": "Select tier",
"productForm_sizeMl": "Size (ml)",
"productForm_fullWeightG": "Full weight (g)",
"productForm_emptyWeightG": "Empty weight (g)",
"productForm_paoMonths": "PAO (months)",
"productForm_phMin": "pH min",
"productForm_phMax": "pH max",
"productForm_usageNotes": "Usage notes",
"productForm_usageNotesPlaceholder": "e.g. Apply to damp skin, avoid eye area",
"productForm_safetyFlags": "Safety flags",
"productForm_fragranceFree": "Fragrance-free",
"productForm_essentialOilsFree": "Essential oils-free",
"productForm_alcoholDenatFree": "Alcohol denat-free",
"productForm_pregnancySafe": "Pregnancy safe",
"productForm_usageConstraints": "Usage constraints",
"productForm_minIntervalHours": "Min interval (hours)",
"productForm_maxFrequencyPerWeek": "Max uses per week",
"productForm_isMedication": "Is medication",
"productForm_isTool": "Is tool (e.g. dermaroller)",
"productForm_needleLengthMm": "Needle length (mm, tools only)",
"productForm_personalNotes": "Personal notes",
"productForm_repurchaseIntent": "Repurchase intent",
"productForm_toleranceNotes": "Tolerance notes",
"productForm_toleranceNotesPlaceholder": "e.g. Causes mild stinging, fine after 2 weeks",
"productForm_aiPrefill": "AI pre-fill",
"productForm_aiPrefillText": "Paste product description from a website, ingredient list, or other text. AI will fill in available fields — you can review and correct before saving.",
"productForm_pasteText": "Paste product description, INCI ingredients here...",
"productForm_parseWithAI": "Fill fields (AI)",
"productForm_parsing": "Processing…",
"productForm_basicInfo": "Basic info",
"productForm_name": "Name *",
"productForm_namePlaceholder": "e.g. Hydro Boost Water Gel",
"productForm_brand": "Brand *",
"productForm_brandPlaceholder": "e.g. Neutrogena",
"productForm_lineName": "Line / series",
"productForm_lineNamePlaceholder": "e.g. Hydro Boost",
"productForm_url": "URL",
"productForm_sku": "SKU",
"productForm_skuPlaceholder": "e.g. NTR-HB-50",
"productForm_barcode": "Barcode / EAN",
"productForm_barcodePlaceholder": "e.g. 3614273258975",
"productForm_classification": "Classification",
"productForm_category": "Category *",
"productForm_selectCategory": "Select category",
"productForm_time": "Time *",
"productForm_timeOptions": "AM / PM / Both",
"productForm_timeBoth": "Both",
"productForm_leaveOn": "Leave-on *",
"productForm_leaveOnYes": "Yes (leave-on)",
"productForm_leaveOnNo": "No (rinse-off)",
"productForm_texture": "Texture",
"productForm_selectTexture": "Select texture",
"productForm_absorptionSpeed": "Absorption speed",
"productForm_selectSpeed": "Select speed",
"productForm_skinProfile": "Skin profile",
"productForm_recommendedFor": "Recommended for skin types",
"productForm_targetConcerns": "Target concerns",
"productForm_contraindications": "Contraindications (one per line)",
"productForm_contraindicationsPlaceholder": "e.g. active rosacea flares",
"productForm_ingredients": "Ingredients",
"productForm_inciList": "INCI list (one ingredient per line)",
"productForm_activeIngredients": "Active ingredients",
"productForm_addActive": "+ Add active",
"productForm_noActives": "No actives added yet.",
"productForm_activeName": "Name",
"productForm_activePercent": "%",
"productForm_activeStrength": "Strength",
"productForm_activeIrritation": "Irritation",
"productForm_activeFunctions": "Functions",
"productForm_effectProfile": "Effect profile (05)",
"productForm_interactions": "Interactions",
"productForm_synergizesWith": "Synergizes with (one per line)",
"productForm_incompatibleWith": "Incompatible with",
"productForm_addIncompatibility": "+ Add incompatibility",
"productForm_noIncompatibilities": "No incompatibilities added.",
"productForm_incompTarget": "Target ingredient",
"productForm_incompScope": "Scope",
"productForm_incompReason": "Reason (optional)",
"productForm_incompReasonPlaceholder": "e.g. reduces efficacy",
"productForm_incompScopeSelect": "Select…",
"productForm_contextRules": "Context rules",
"productForm_ctxAfterShaving": "Safe after shaving",
"productForm_ctxAfterAcids": "Safe after acids",
"productForm_ctxAfterRetinoids": "Safe after retinoids",
"productForm_ctxCompromisedBarrier": "Safe with compromised barrier",
"productForm_ctxLowUvOnly": "Low UV only (evening/covered)",
"productForm_productDetails": "Product details",
"productForm_priceTier": "Price tier",
"productForm_selectTier": "Select tier",
"productForm_sizeMl": "Size (ml)",
"productForm_fullWeightG": "Full weight (g)",
"productForm_emptyWeightG": "Empty weight (g)",
"productForm_paoMonths": "PAO (months)",
"productForm_phMin": "pH min",
"productForm_phMax": "pH max",
"productForm_usageNotes": "Usage notes",
"productForm_usageNotesPlaceholder": "e.g. Apply to damp skin, avoid eye area",
"productForm_safetyFlags": "Safety flags",
"productForm_fragranceFree": "Fragrance-free",
"productForm_essentialOilsFree": "Essential oils-free",
"productForm_alcoholDenatFree": "Alcohol denat-free",
"productForm_pregnancySafe": "Pregnancy safe",
"productForm_usageConstraints": "Usage constraints",
"productForm_minIntervalHours": "Min interval (hours)",
"productForm_maxFrequencyPerWeek": "Max uses per week",
"productForm_isMedication": "Is medication",
"productForm_isTool": "Is tool (e.g. dermaroller)",
"productForm_needleLengthMm": "Needle length (mm, tools only)",
"productForm_personalNotes": "Personal notes",
"productForm_repurchaseIntent": "Repurchase intent",
"productForm_toleranceNotes": "Tolerance notes",
"productForm_toleranceNotesPlaceholder": "e.g. Causes mild stinging, fine after 2 weeks",
"productForm_categoryCleanser": "Cleanser",
"productForm_categoryToner": "Toner",
"productForm_categoryEssence": "Essence",
"productForm_categorySerum": "Serum",
"productForm_categoryMoisturizer": "Moisturizer",
"productForm_categorySpf": "SPF",
"productForm_categoryMask": "Mask",
"productForm_categoryExfoliant": "Exfoliant",
"productForm_categoryHairTreatment": "Hair treatment",
"productForm_categoryTool": "Tool",
"productForm_categorySpotTreatment": "Spot treatment",
"productForm_categoryOil": "Oil",
"productForm_categoryCleanser": "Cleanser",
"productForm_categoryToner": "Toner",
"productForm_categoryEssence": "Essence",
"productForm_categorySerum": "Serum",
"productForm_categoryMoisturizer": "Moisturizer",
"productForm_categorySpf": "SPF",
"productForm_categoryMask": "Mask",
"productForm_categoryExfoliant": "Exfoliant",
"productForm_categoryHairTreatment": "Hair treatment",
"productForm_categoryTool": "Tool",
"productForm_categorySpotTreatment": "Spot treatment",
"productForm_categoryOil": "Oil",
"productForm_textureWatery": "Watery",
"productForm_textureGel": "Gel",
"productForm_textureEmulsion": "Emulsion",
"productForm_textureCream": "Cream",
"productForm_textureOil": "Oil",
"productForm_textureBalm": "Balm",
"productForm_textureFoam": "Foam",
"productForm_textureFluid": "Fluid",
"productForm_textureWatery": "Watery",
"productForm_textureGel": "Gel",
"productForm_textureEmulsion": "Emulsion",
"productForm_textureCream": "Cream",
"productForm_textureOil": "Oil",
"productForm_textureBalm": "Balm",
"productForm_textureFoam": "Foam",
"productForm_textureFluid": "Fluid",
"productForm_absorptionVeryFast": "Very fast",
"productForm_absorptionFast": "Fast",
"productForm_absorptionModerate": "Moderate",
"productForm_absorptionSlow": "Slow",
"productForm_absorptionVerySlow": "Very slow",
"productForm_absorptionVeryFast": "Very fast",
"productForm_absorptionFast": "Fast",
"productForm_absorptionModerate": "Moderate",
"productForm_absorptionSlow": "Slow",
"productForm_absorptionVerySlow": "Very slow",
"productForm_priceBudget": "Budget",
"productForm_priceMid": "Mid",
"productForm_pricePremium": "Premium",
"productForm_priceLuxury": "Luxury",
"productForm_priceBudget": "Budget",
"productForm_priceMid": "Mid",
"productForm_pricePremium": "Premium",
"productForm_priceLuxury": "Luxury",
"productForm_skinTypeDry": "dry",
"productForm_skinTypeOily": "oily",
"productForm_skinTypeCombination": "combination",
"productForm_skinTypeSensitive": "sensitive",
"productForm_skinTypeNormal": "normal",
"productForm_skinTypeAcneProne": "acne prone",
"productForm_skinTypeDry": "dry",
"productForm_skinTypeOily": "oily",
"productForm_skinTypeCombination": "combination",
"productForm_skinTypeSensitive": "sensitive",
"productForm_skinTypeNormal": "normal",
"productForm_skinTypeAcneProne": "acne prone",
"productForm_concernAcne": "acne",
"productForm_concernRosacea": "rosacea",
"productForm_concernHyperpigmentation": "hyperpigmentation",
"productForm_concernAging": "aging",
"productForm_concernDehydration": "dehydration",
"productForm_concernRedness": "redness",
"productForm_concernDamagedBarrier": "damaged barrier",
"productForm_concernPoreVisibility": "pore visibility",
"productForm_concernUnevenTexture": "uneven texture",
"productForm_concernHairGrowth": "hair growth",
"productForm_concernSebumExcess": "sebum excess",
"productForm_concernAcne": "acne",
"productForm_concernRosacea": "rosacea",
"productForm_concernHyperpigmentation": "hyperpigmentation",
"productForm_concernAging": "aging",
"productForm_concernDehydration": "dehydration",
"productForm_concernRedness": "redness",
"productForm_concernDamagedBarrier": "damaged barrier",
"productForm_concernPoreVisibility": "pore visibility",
"productForm_concernUnevenTexture": "uneven texture",
"productForm_concernHairGrowth": "hair growth",
"productForm_concernSebumExcess": "sebum excess",
"productForm_fnHumectant": "humectant",
"productForm_fnEmollient": "emollient",
"productForm_fnOcclusive": "occlusive",
"productForm_fnExfoliantAha": "AHA exfoliant",
"productForm_fnExfoliantBha": "BHA exfoliant",
"productForm_fnExfoliantPha": "PHA exfoliant",
"productForm_fnRetinoid": "retinoid",
"productForm_fnAntioxidant": "antioxidant",
"productForm_fnSoothing": "soothing",
"productForm_fnBarrierSupport": "barrier support",
"productForm_fnBrightening": "brightening",
"productForm_fnAntiAcne": "anti-acne",
"productForm_fnCeramide": "ceramide",
"productForm_fnNiacinamide": "niacinamide",
"productForm_fnSunscreen": "sunscreen",
"productForm_fnPeptide": "peptide",
"productForm_fnHairGrowth": "hair growth stimulant",
"productForm_fnPrebiotic": "prebiotic",
"productForm_fnVitaminC": "vitamin C",
"productForm_fnAntiAging": "anti-aging",
"productForm_fnHumectant": "humectant",
"productForm_fnEmollient": "emollient",
"productForm_fnOcclusive": "occlusive",
"productForm_fnExfoliantAha": "AHA exfoliant",
"productForm_fnExfoliantBha": "BHA exfoliant",
"productForm_fnExfoliantPha": "PHA exfoliant",
"productForm_fnRetinoid": "retinoid",
"productForm_fnAntioxidant": "antioxidant",
"productForm_fnSoothing": "soothing",
"productForm_fnBarrierSupport": "barrier support",
"productForm_fnBrightening": "brightening",
"productForm_fnAntiAcne": "anti-acne",
"productForm_fnCeramide": "ceramide",
"productForm_fnNiacinamide": "niacinamide",
"productForm_fnSunscreen": "sunscreen",
"productForm_fnPeptide": "peptide",
"productForm_fnHairGrowth": "hair growth stimulant",
"productForm_fnPrebiotic": "prebiotic",
"productForm_fnVitaminC": "vitamin C",
"productForm_fnAntiAging": "anti-aging",
"productForm_scopeSameStep": "same step",
"productForm_scopeSameDay": "same day",
"productForm_scopeSamePeriod": "same period",
"productForm_scopeSameStep": "same step",
"productForm_scopeSameDay": "same day",
"productForm_scopeSamePeriod": "same period",
"productForm_strengthLow": "1 Low",
"productForm_strengthMedium": "2 Medium",
"productForm_strengthHigh": "3 High",
"productForm_strengthLow": "1 Low",
"productForm_strengthMedium": "2 Medium",
"productForm_strengthHigh": "3 High",
"productForm_effectHydrationImmediate": "Hydration (immediate)",
"productForm_effectHydrationLongTerm": "Hydration (long term)",
"productForm_effectBarrierRepair": "Barrier repair",
"productForm_effectSoothing": "Soothing",
"productForm_effectExfoliation": "Exfoliation",
"productForm_effectRetinoid": "Retinoid activity",
"productForm_effectIrritation": "Irritation risk",
"productForm_effectComedogenic": "Comedogenic risk",
"productForm_effectBarrierDisruption": "Barrier disruption risk",
"productForm_effectDryness": "Dryness risk",
"productForm_effectBrightening": "Brightening",
"productForm_effectAntiAcne": "Anti-acne",
"productForm_effectAntiAging": "Anti-aging",
"productForm_effectHydrationImmediate": "Hydration (immediate)",
"productForm_effectHydrationLongTerm": "Hydration (long term)",
"productForm_effectBarrierRepair": "Barrier repair",
"productForm_effectSoothing": "Soothing",
"productForm_effectExfoliation": "Exfoliation",
"productForm_effectRetinoid": "Retinoid activity",
"productForm_effectIrritation": "Irritation risk",
"productForm_effectComedogenic": "Comedogenic risk",
"productForm_effectBarrierDisruption": "Barrier disruption risk",
"productForm_effectDryness": "Dryness risk",
"productForm_effectBrightening": "Brightening",
"productForm_effectAntiAcne": "Anti-acne",
"productForm_effectAntiAging": "Anti-aging",
"lang_pl": "PL",
"lang_en": "EN"
"lang_pl": "PL",
"lang_en": "EN"
}

View file

@ -1,437 +1,536 @@
{
"nav_dashboard": "Dashboard",
"nav_products": "Produkty",
"nav_routines": "Rutyny",
"nav_grooming": "Pielęgnacja",
"nav_medications": "Leki",
"nav_labResults": "Wyniki badań",
"nav_skin": "Skóra",
"nav_appName": "innercontext",
"nav_appSubtitle": "zdrowie & pielęgnacja",
"nav_dashboard": "Dashboard",
"nav_products": "Produkty",
"nav_routines": "Rutyny",
"nav_grooming": "Pielęgnacja",
"nav_medications": "Leki",
"nav_labResults": "Wyniki badań",
"nav_skin": "Skóra",
"nav_appName": "innercontext",
"nav_appSubtitle": "zdrowie & pielęgnacja",
"common_save": "Zapisz",
"common_cancel": "Anuluj",
"common_add": "Dodaj",
"common_edit": "Edytuj",
"common_delete": "Usuń",
"common_saved": "Zapisano.",
"common_select": "Wybierz",
"common_unknown": "Nieznane",
"common_yes": "Tak",
"common_no": "Nie",
"common_unknown_value": "Nieznane",
"common_optional_notes": "opcjonalnie",
"common_steps": "kroków",
"common_save": "Zapisz",
"common_cancel": "Anuluj",
"common_add": "Dodaj",
"common_edit": "Edytuj",
"common_delete": "Usuń",
"common_saved": "Zapisano.",
"common_select": "Wybierz",
"common_unknown": "Nieznane",
"common_yes": "Tak",
"common_no": "Nie",
"common_unknown_value": "Nieznane",
"common_optional_notes": "opcjonalnie",
"common_steps": "kroków",
"dashboard_title": "Dashboard",
"dashboard_subtitle": "Przegląd zdrowia i pielęgnacji",
"dashboard_latestSnapshot": "Ostatni stan skóry",
"dashboard_recentRoutines": "Ostatnie rutyny",
"dashboard_noSnapshots": "Brak wpisów o stanie skóry.",
"dashboard_noRoutines": "Brak rutyno w ciągu ostatnich 2 tygodni.",
"dashboard_title": "Dashboard",
"dashboard_subtitle": "Przegląd zdrowia i pielęgnacji",
"dashboard_latestSnapshot": "Ostatni stan skóry",
"dashboard_recentRoutines": "Ostatnie rutyny",
"dashboard_noSnapshots": "Brak wpisów o stanie skóry.",
"dashboard_noRoutines": "Brak rutyn w ciągu ostatnich 2 tygodni.",
"products_title": "Produkty",
"products_count": "{count} produktów",
"products_addNew": "+ Dodaj produkt",
"products_noProducts": "Nie znaleziono produktów.",
"products_filterAll": "Wszystkie",
"products_filterOwned": "Posiadane",
"products_filterUnowned": "Nieposiadane",
"products_colName": "Nazwa",
"products_colBrand": "Marka",
"products_colTargets": "Cele",
"products_colTime": "Pora",
"products_newTitle": "Nowy produkt",
"products_backToList": "← Produkty",
"products_createProduct": "Utwórz produkt",
"products_saveChanges": "Zapisz zmiany",
"products_deleteProduct": "Usuń produkt",
"products_confirmDelete": "Usunąć ten produkt?",
"products_noInventory": "Brak opakowań w magazynie.",
"products_title": "Produkty",
"products_count": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} produkt",
"countPlural=few": "{count} produkty",
"countPlural=many": "{count} produktów",
"countPlural=*": "{count} produktów"
}
}
],
"products_addNew": "+ Dodaj produkt",
"products_suggest": "Sugeruj",
"products_suggestTitle": "Sugestie zakupowe",
"products_suggestSubtitle": "Co warto kupić?",
"products_suggestDescription": "Na podstawie Twojego stanu skóry i posiadanych produktów zasugeruję typy produktów, które mogłyby uzupełnić Twoją rutynę.",
"products_suggestGenerating": "Analizuję...",
"products_suggestBtn": "Generuj sugestie",
"products_suggestResults": "Propozycje",
"products_suggestTime": "Pora",
"products_suggestFrequency": "Częstotliwość",
"products_suggestRegenerate": "Wygeneruj ponownie",
"products_suggestNoResults": "Brak propozycji.",
"products_noProducts": "Nie znaleziono produktów.",
"products_filterAll": "Wszystkie",
"products_filterOwned": "Posiadane",
"products_filterUnowned": "Nieposiadane",
"products_colName": "Nazwa",
"products_colBrand": "Marka",
"products_colTargets": "Cele",
"products_colTime": "Pora",
"products_newTitle": "Nowy produkt",
"products_backToList": "← Produkty",
"products_createProduct": "Utwórz produkt",
"products_saveChanges": "Zapisz zmiany",
"products_deleteProduct": "Usuń produkt",
"products_confirmDelete": "Usunąć ten produkt?",
"products_noInventory": "Brak opakowań w magazynie.",
"inventory_title": "Opakowania ({count})",
"inventory_addPackage": "+ Dodaj opakowanie",
"inventory_packageAdded": "Opakowanie dodane.",
"inventory_packageUpdated": "Opakowanie zaktualizowane.",
"inventory_packageDeleted": "Opakowanie usunięte.",
"inventory_alreadyOpened": "Już otwarte",
"inventory_openedDate": "Data otwarcia",
"inventory_finishedDate": "Data skończenia",
"inventory_expiryDate": "Data ważności",
"inventory_currentWeight": "Aktualna waga (g)",
"inventory_lastWeighed": "Ostatnie ważenie",
"inventory_notes": "Notatki",
"inventory_badgeOpen": "Otwarte",
"inventory_badgeSealed": "Zamknięte",
"inventory_badgeFinished": "Skończone",
"inventory_exp": "Wazność:",
"inventory_opened": "Otwarto:",
"inventory_finished": "Skończono:",
"inventory_remaining": "g pozostało",
"inventory_weighed": "Ważono:",
"inventory_confirmDelete": "Usunąć to opakowanie?",
"inventory_title": "Opakowania ({count})",
"inventory_addPackage": "+ Dodaj opakowanie",
"inventory_packageAdded": "Opakowanie dodane.",
"inventory_packageUpdated": "Opakowanie zaktualizowane.",
"inventory_packageDeleted": "Opakowanie usunięte.",
"inventory_alreadyOpened": "Już otwarte",
"inventory_openedDate": "Data otwarcia",
"inventory_finishedDate": "Data skończenia",
"inventory_expiryDate": "Data ważności",
"inventory_currentWeight": "Aktualna waga (g)",
"inventory_lastWeighed": "Ostatnie ważenie",
"inventory_notes": "Notatki",
"inventory_badgeOpen": "Otwarte",
"inventory_badgeSealed": "Zamknięte",
"inventory_badgeFinished": "Skończone",
"inventory_exp": "Wazność:",
"inventory_opened": "Otwarto:",
"inventory_finished": "Skończono:",
"inventory_remaining": "g pozostało",
"inventory_weighed": "Ważono:",
"inventory_confirmDelete": "Usunąć to opakowanie?",
"routines_title": "Rutyny",
"routines_count": "{count} rutyno (ostatnie 30 dni)",
"routines_suggestAI": "Zaproponuj rutynę AI",
"routines_addNew": "+ Nowa rutyna",
"routines_noRoutines": "Nie znaleziono rutyno.",
"routines_newTitle": "Nowa rutyna",
"routines_backToList": "← Rutyny",
"routines_detailsTitle": "Szczegóły rutyny",
"routines_date": "Data *",
"routines_amOrPm": "AM lub PM *",
"routines_notes": "Notatki",
"routines_notesPlaceholder": "Opcjonalne notatki",
"routines_createRoutine": "Utwórz rutynę",
"routines_deleteRoutine": "Usuń rutynę",
"routines_confirmDelete": "Usunąć tę rutynę?",
"routines_steps": "Kroki ({count})",
"routines_addStep": "+ Dodaj krok",
"routines_addStepTitle": "Dodaj krok",
"routines_product": "Produkt",
"routines_selectProduct": "Wybierz produkt",
"routines_dose": "Dawka",
"routines_dosePlaceholder": "np. 2 pompki",
"routines_region": "Okolica",
"routines_regionPlaceholder": "np. twarz",
"routines_addStepBtn": "Dodaj krok",
"routines_unknownStep": "Nieznany krok",
"routines_noSteps": "Brak kroków.",
"routines_title": "Rutyny",
"routines_count": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} rutyna (ostatnie 30 dni)",
"countPlural=few": "{count} rutyny (ostatnie 30 dni)",
"countPlural=many": "{count} rutyn (ostatnie 30 dni)",
"countPlural=*": "{count} rutyn (ostatnie 30 dni)"
}
}
],
"routines_suggestAI": "Zaproponuj rutynę AI",
"routines_addNew": "+ Nowa rutyna",
"routines_noRoutines": "Nie znaleziono rutyn.",
"routines_newTitle": "Nowa rutyna",
"routines_backToList": "← Rutyny",
"routines_detailsTitle": "Szczegóły rutyny",
"routines_date": "Data *",
"routines_amOrPm": "AM lub PM *",
"routines_notes": "Notatki",
"routines_notesPlaceholder": "Opcjonalne notatki",
"routines_createRoutine": "Utwórz rutynę",
"routines_deleteRoutine": "Usuń rutynę",
"routines_confirmDelete": "Usunąć tę rutynę?",
"routines_steps": "Kroki ({count})",
"routines_addStep": "+ Dodaj krok",
"routines_addStepTitle": "Dodaj krok",
"routines_product": "Produkt",
"routines_selectProduct": "Wybierz produkt",
"routines_dose": "Dawka",
"routines_dosePlaceholder": "np. 2 pompki",
"routines_region": "Okolica",
"routines_regionPlaceholder": "np. twarz",
"routines_addStepBtn": "Dodaj krok",
"routines_unknownStep": "Nieznany krok",
"routines_noSteps": "Brak kroków.",
"grooming_title": "Harmonogram pielęgnacji",
"grooming_backToRoutines": "← Rutyny",
"grooming_addEntry": "+ Dodaj wpis",
"grooming_entryAdded": "Wpis dodany.",
"grooming_entryUpdated": "Wpis zaktualizowany.",
"grooming_entryDeleted": "Wpis usunięty.",
"grooming_dayOfWeek": "Dzień tygodnia",
"grooming_action": "Czynność",
"grooming_notesOptional": "Notatki (opcjonalnie)",
"grooming_notesPlaceholder": "np. co 2 tygodnie",
"grooming_noEntries": "Brak wpisów. Kliknij \"+ Dodaj wpis\", aby zacząć.",
"grooming_confirmDelete": "Usunąć ten wpis?",
"grooming_actionShavingRazor": "Golenie maszynką",
"grooming_actionShavingOneblade": "Golenie OneBlade",
"grooming_actionDermarolling": "Dermarolling",
"grooming_dayMonday": "Poniedziałek",
"grooming_dayTuesday": "Wtorek",
"grooming_dayWednesday": "Środa",
"grooming_dayThursday": "Czwartek",
"grooming_dayFriday": "Piątek",
"grooming_daySaturday": "Sobota",
"grooming_daySunday": "Niedziela",
"grooming_title": "Harmonogram pielęgnacji",
"grooming_backToRoutines": "← Rutyny",
"grooming_addEntry": "+ Dodaj wpis",
"grooming_entryAdded": "Wpis dodany.",
"grooming_entryUpdated": "Wpis zaktualizowany.",
"grooming_entryDeleted": "Wpis usunięty.",
"grooming_dayOfWeek": "Dzień tygodnia",
"grooming_action": "Czynność",
"grooming_notesOptional": "Notatki (opcjonalnie)",
"grooming_notesPlaceholder": "np. co 2 tygodnie",
"grooming_noEntries": "Brak wpisów. Kliknij \"+ Dodaj wpis\", aby zacząć.",
"grooming_confirmDelete": "Usunąć ten wpis?",
"grooming_actionShavingRazor": "Golenie maszynką",
"grooming_actionShavingOneblade": "Golenie OneBlade",
"grooming_actionDermarolling": "Dermarolling",
"grooming_dayMonday": "Poniedziałek",
"grooming_dayTuesday": "Wtorek",
"grooming_dayWednesday": "Środa",
"grooming_dayThursday": "Czwartek",
"grooming_dayFriday": "Piątek",
"grooming_daySaturday": "Sobota",
"grooming_daySunday": "Niedziela",
"suggest_title": "Propozycja rutyny AI",
"suggest_backToRoutines": "← Rutyny",
"suggest_singleTab": "Jedna rutyna",
"suggest_batchTab": "Batch / Urlop",
"suggest_singleParams": "Parametry",
"suggest_date": "Data",
"suggest_timeOfDay": "Pora dnia",
"suggest_contextLabel": "Dodatkowy kontekst dla AI",
"suggest_contextOptional": "(opcjonalny)",
"suggest_contextPlaceholder": "np. wieczór imprezowy, skupiam się na nawilżeniu...",
"suggest_generateBtn": "Generuj propozycję",
"suggest_generating": "Generuję…",
"suggest_proposalTitle": "Propozycja",
"suggest_saveRoutine": "Zapisz rutynę",
"suggest_saving": "Zapisuję…",
"suggest_regenerate": "Wygeneruj ponownie",
"suggest_batchRange": "Zakres dat",
"suggest_fromDate": "Od",
"suggest_toDate": "Do (max 14 dni)",
"suggest_batchContextLabel": "Kontekst / cel wyjazdu",
"suggest_batchContextPlaceholder": "np. słoneczna podróż do Włoch, aktywny urlop górski...",
"suggest_generatePlan": "Generuj plan",
"suggest_generatingPlan": "Generuję plan…",
"suggest_planTitle": "Plan ({count} dni)",
"suggest_saveAllRoutines": "Zapisz wszystkie rutyny",
"suggest_amSteps": "kroków",
"suggest_pmSteps": "kroków",
"suggest_noAmSteps": "Brak kroków AM.",
"suggest_noPmSteps": "Brak kroków PM.",
"suggest_errorDefault": "Błąd podczas generowania.",
"suggest_errorBatch": "Błąd podczas generowania planu.",
"suggest_errorSave": "Błąd podczas zapisywania.",
"suggest_amMorning": "AM (rano)",
"suggest_pmEvening": "PM (wieczór)",
"suggest_title": "Propozycja rutyny AI",
"suggest_backToRoutines": "← Rutyny",
"suggest_singleTab": "Jedna rutyna",
"suggest_batchTab": "Batch / Urlop",
"suggest_singleParams": "Parametry",
"suggest_date": "Data",
"suggest_timeOfDay": "Pora dnia",
"suggest_contextLabel": "Dodatkowy kontekst dla AI",
"suggest_contextOptional": "(opcjonalny)",
"suggest_contextPlaceholder": "np. wieczór imprezowy, skupiam się na nawilżeniu...",
"suggest_leavingHomeLabel": "Wychodzę dziś z domu",
"suggest_leavingHomeHint": "Wpływa na wybór SPF — zaznaczone: SPF50+, odznaczone: SPF30.",
"suggest_minoxidilToggleLabel": "Priorytet: gęstość brody/wąsów (minoksydyl)",
"suggest_minoxidilToggleHint": "Po włączeniu AI jawnie uwzględni minoksydyl dla obszaru brody/wąsów, jeśli jest dostępny.",
"suggest_generateBtn": "Generuj propozycję",
"suggest_generating": "Generuję…",
"suggest_proposalTitle": "Propozycja",
"suggest_saveRoutine": "Zapisz rutynę",
"suggest_saving": "Zapisuję…",
"suggest_regenerate": "Wygeneruj ponownie",
"suggest_batchRange": "Zakres dat",
"suggest_fromDate": "Od",
"suggest_toDate": "Do (max 14 dni)",
"suggest_batchContextLabel": "Kontekst / cel wyjazdu",
"suggest_batchContextPlaceholder": "np. słoneczna podróż do Włoch, aktywny urlop górski...",
"suggest_generatePlan": "Generuj plan",
"suggest_generatingPlan": "Generuję plan…",
"suggest_planTitle": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Plan ({count} dzień)",
"countPlural=few": "Plan ({count} dni)",
"countPlural=many": "Plan ({count} dni)",
"countPlural=*": "Plan ({count} dni)"
}
}
],
"suggest_saveAllRoutines": "Zapisz wszystkie rutyny",
"suggest_amSteps": "kroków",
"suggest_pmSteps": "kroków",
"suggest_noAmSteps": "Brak kroków AM.",
"suggest_noPmSteps": "Brak kroków PM.",
"suggest_errorDefault": "Błąd podczas generowania.",
"suggest_errorBatch": "Błąd podczas generowania planu.",
"suggest_errorSave": "Błąd podczas zapisywania.",
"suggest_amMorning": "AM (rano)",
"suggest_pmEvening": "PM (wieczór)",
"suggest_summaryPrimaryGoal": "Główny cel",
"suggest_summaryConfidence": "Pewność",
"suggest_summaryConstraints": "Ograniczenia",
"suggest_stepOptionalBadge": "opcjonalny",
"medications_title": "Leki",
"medications_count": "{count} wpisów",
"medications_addNew": "+ Dodaj lek",
"medications_newTitle": "Nowy lek",
"medications_kind": "Rodzaj",
"medications_productName": "Nazwa produktu *",
"medications_productNamePlaceholder": "np. Witamina D3",
"medications_activeSubstance": "Substancja czynna",
"medications_activeSubstancePlaceholder": "np. cholekalcyferol",
"medications_notes": "Notatki",
"medications_added": "Lek dodany.",
"medications_usages": "{count} użyć",
"medications_noMedications": "Brak leków.",
"medications_kindPrescription": "Na receptę",
"medications_kindOtc": "OTC (bez recepty)",
"medications_kindSupplement": "Suplement",
"medications_kindHerbal": "Zioła",
"medications_kindOther": "Inne",
"medications_title": "Leki",
"medications_count": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} wpis",
"countPlural=few": "{count} wpisy",
"countPlural=many": "{count} wpisów",
"countPlural=*": "{count} wpisów"
}
}
],
"medications_addNew": "+ Dodaj lek",
"medications_newTitle": "Nowy lek",
"medications_kind": "Rodzaj",
"medications_productName": "Nazwa produktu *",
"medications_productNamePlaceholder": "np. Witamina D3",
"medications_activeSubstance": "Substancja czynna",
"medications_activeSubstancePlaceholder": "np. cholekalcyferol",
"medications_notes": "Notatki",
"medications_added": "Lek dodany.",
"medications_usages": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} użycie",
"countPlural=few": "{count} użycia",
"countPlural=many": "{count} użyć",
"countPlural=*": "{count} użyć"
}
}
],
"medications_noMedications": "Brak leków.",
"medications_kindPrescription": "Na receptę",
"medications_kindOtc": "OTC (bez recepty)",
"medications_kindSupplement": "Suplement",
"medications_kindHerbal": "Zioła",
"medications_kindOther": "Inne",
"labResults_title": "Wyniki badań",
"labResults_count": "{count} wyników",
"labResults_addNew": "+ Dodaj wynik",
"labResults_newTitle": "Nowy wynik badania",
"labResults_flagFilter": "Flaga:",
"labResults_flagAll": "Wszystkie",
"labResults_flagNone": "Brak",
"labResults_date": "Data *",
"labResults_loincCode": "Kod LOINC *",
"labResults_testName": "Nazwa badania",
"labResults_testNamePlaceholder": "np. Hemoglobina",
"labResults_lab": "Laboratorium",
"labResults_labPlaceholder": "np. LabCorp",
"labResults_value": "Wartość",
"labResults_unit": "Jednostka",
"labResults_unitPlaceholder": "np. g/dL",
"labResults_flag": "Flaga",
"labResults_added": "Wynik dodany.",
"labResults_colDate": "Data",
"labResults_colTest": "Badanie",
"labResults_colLoinc": "LOINC",
"labResults_colValue": "Wartość",
"labResults_colFlag": "Flaga",
"labResults_colLab": "Lab",
"labResults_noResults": "Nie znaleziono wyników badań.",
"labResults_title": "Wyniki badań",
"labResults_count": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} wynik",
"countPlural=few": "{count} wyniki",
"countPlural=many": "{count} wyników",
"countPlural=*": "{count} wyników"
}
}
],
"labResults_addNew": "+ Dodaj wynik",
"labResults_newTitle": "Nowy wynik badania",
"labResults_flagFilter": "Flaga:",
"labResults_flagAll": "Wszystkie",
"labResults_flagNone": "Brak",
"labResults_date": "Data *",
"labResults_loincCode": "Kod LOINC *",
"labResults_testName": "Nazwa badania",
"labResults_testNamePlaceholder": "np. Hemoglobina",
"labResults_lab": "Laboratorium",
"labResults_labPlaceholder": "np. LabCorp",
"labResults_value": "Wartość",
"labResults_unit": "Jednostka",
"labResults_unitPlaceholder": "np. g/dL",
"labResults_flag": "Flaga",
"labResults_added": "Wynik dodany.",
"labResults_colDate": "Data",
"labResults_colTest": "Badanie",
"labResults_colLoinc": "LOINC",
"labResults_colValue": "Wartość",
"labResults_colFlag": "Flaga",
"labResults_colLab": "Lab",
"labResults_noResults": "Nie znaleziono wyników badań.",
"skin_title": "Stan skóry",
"skin_count": "{count} wpisów",
"skin_addNew": "+ Dodaj wpis",
"skin_aiAnalysisTitle": "Analiza AI ze zdjęć",
"skin_aiUploadText": "Prześlij 13 zdjęcia skóry. AI wypełni pola formularza poniżej.",
"skin_analyzePhotos": "Analizuj zdjęcia",
"skin_analyzing": "Analizuję…",
"skin_newSnapshotTitle": "Nowy wpis",
"skin_date": "Data *",
"skin_overallState": "Ogólny stan",
"skin_texture": "Tekstura",
"skin_skinType": "Typ skóry",
"skin_barrierState": "Stan bariery",
"skin_hydration": "Nawilżenie (15)",
"skin_sensitivity": "Wrażliwość (15)",
"skin_sebumTzone": "Sebum T-zone (15)",
"skin_sebumCheeks": "Sebum policzki (15)",
"skin_activeConcerns": "Aktywne problemy (przecinek)",
"skin_activeConcernsPlaceholder": "trądzik, zaczerwienienie, odwodnienie",
"skin_notes": "Notatki",
"skin_addSnapshot": "Dodaj wpis",
"skin_snapshotAdded": "Wpis dodany.",
"skin_snapshotUpdated": "Wpis zaktualizowany.",
"skin_snapshotDeleted": "Wpis usunięty.",
"skin_noSnapshots": "Brak wpisów o stanie skóry.",
"skin_hydrationLabel": "Nawilżenie",
"skin_sensitivityLabel": "Wrażliwość",
"skin_barrierLabel": "Bariera",
"skin_stateExcellent": "doskonały",
"skin_stateGood": "dobry",
"skin_stateFair": "przeciętny",
"skin_statePoor": "zły",
"skin_textureSmooth": "gładka",
"skin_textureRough": "szorstka",
"skin_textureFlaky": "łuszcząca się",
"skin_textureBumpy": "nierówna",
"skin_barrierIntact": "nienaruszona",
"skin_barrierMildly": "lekko naruszona",
"skin_barrierCompromised": "naruszona",
"skin_typeDry": "sucha",
"skin_typeOily": "tłusta",
"skin_typeCombination": "mieszana",
"skin_typeSensitive": "wrażliwa",
"skin_typeNormal": "normalna",
"skin_typeAcneProne": "trądzikowa",
"skin_title": "Stan skóry",
"skin_count": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "{count} wpis",
"countPlural=few": "{count} wpisy",
"countPlural=many": "{count} wpisów",
"countPlural=*": "{count} wpisów"
}
}
],
"skin_addNew": "+ Dodaj wpis",
"skin_aiAnalysisTitle": "Analiza AI ze zdjęć",
"skin_aiUploadText": "Prześlij 13 zdjęcia skóry. AI wypełni pola formularza poniżej.",
"skin_analyzePhotos": "Analizuj zdjęcia",
"skin_analyzing": "Analizuję…",
"skin_newSnapshotTitle": "Nowy wpis",
"skin_date": "Data *",
"skin_overallState": "Ogólny stan",
"skin_texture": "Tekstura",
"skin_skinType": "Typ skóry",
"skin_barrierState": "Stan bariery",
"skin_hydration": "Nawilżenie (15)",
"skin_sensitivity": "Wrażliwość (15)",
"skin_sebumTzone": "Sebum T-zone (15)",
"skin_sebumCheeks": "Sebum policzki (15)",
"skin_activeConcerns": "Aktywne problemy (przecinek)",
"skin_activeConcernsPlaceholder": "trądzik, zaczerwienienie, odwodnienie",
"skin_priorities": "Priorytety (przecinek)",
"skin_prioritiesPlaceholder": "wzmocnić barierę, redukować zaczerwienienie",
"skin_prioritiesLabel": "Priorytety",
"skin_notes": "Notatki",
"skin_addSnapshot": "Dodaj wpis",
"skin_snapshotAdded": "Wpis dodany.",
"skin_snapshotUpdated": "Wpis zaktualizowany.",
"skin_snapshotDeleted": "Wpis usunięty.",
"skin_noSnapshots": "Brak wpisów o stanie skóry.",
"skin_hydrationLabel": "Nawilżenie",
"skin_sensitivityLabel": "Wrażliwość",
"skin_barrierLabel": "Bariera",
"skin_stateExcellent": "doskonały",
"skin_stateGood": "dobry",
"skin_stateFair": "przeciętny",
"skin_statePoor": "zły",
"skin_textureSmooth": "gładka",
"skin_textureRough": "szorstka",
"skin_textureFlaky": "łuszcząca się",
"skin_textureBumpy": "nierówna",
"skin_barrierIntact": "nienaruszona",
"skin_barrierMildly": "lekko naruszona",
"skin_barrierCompromised": "naruszona",
"skin_typeDry": "sucha",
"skin_typeOily": "tłusta",
"skin_typeCombination": "mieszana",
"skin_typeSensitive": "wrażliwa",
"skin_typeNormal": "normalna",
"skin_typeAcneProne": "trądzikowa",
"productForm_aiPrefill": "Uzupełnienie AI",
"productForm_aiPrefillText": "Wklej opis produktu ze strony, listę składników lub inny tekst. AI uzupełni dostępne pola — możesz je przejrzeć i poprawić przed zapisem.",
"productForm_pasteText": "Wklej tutaj opis produktu, składniki INCI...",
"productForm_parseWithAI": "Uzupełnij pola (AI)",
"productForm_parsing": "Przetwarzam…",
"productForm_basicInfo": "Informacje podstawowe",
"productForm_name": "Nazwa *",
"productForm_namePlaceholder": "np. Hydro Boost Water Gel",
"productForm_brand": "Marka *",
"productForm_brandPlaceholder": "np. Neutrogena",
"productForm_lineName": "Linia / seria",
"productForm_lineNamePlaceholder": "np. Hydro Boost",
"productForm_url": "URL",
"productForm_sku": "SKU",
"productForm_skuPlaceholder": "np. NTR-HB-50",
"productForm_barcode": "Kod kreskowy / EAN",
"productForm_barcodePlaceholder": "np. 3614273258975",
"productForm_classification": "Klasyfikacja",
"productForm_category": "Kategoria *",
"productForm_selectCategory": "Wybierz kategorię",
"productForm_time": "Pora *",
"productForm_timeOptions": "AM / PM / Oba",
"productForm_timeBoth": "Oba",
"productForm_leaveOn": "Leave-on *",
"productForm_leaveOnYes": "Tak (leave-on)",
"productForm_leaveOnNo": "Nie (rinse-off)",
"productForm_texture": "Tekstura",
"productForm_selectTexture": "Wybierz teksturę",
"productForm_absorptionSpeed": "Szybkość wchłaniania",
"productForm_selectSpeed": "Wybierz szybkość",
"productForm_skinProfile": "Profil skóry",
"productForm_recommendedFor": "Polecane dla typów skóry",
"productForm_targetConcerns": "Problemy docelowe",
"productForm_contraindications": "Przeciwwskazania (jedno na linię)",
"productForm_contraindicationsPlaceholder": "np. aktywna rosacea",
"productForm_ingredients": "Składniki",
"productForm_inciList": "Lista INCI (jeden składnik na linię)",
"productForm_activeIngredients": "Składniki aktywne",
"productForm_addActive": "+ Dodaj aktywny",
"productForm_noActives": "Brak składników aktywnych.",
"productForm_activeName": "Nazwa",
"productForm_activePercent": "%",
"productForm_activeStrength": "Siła",
"productForm_activeIrritation": "Podrażnienie",
"productForm_activeFunctions": "Funkcje",
"productForm_effectProfile": "Profil działania (05)",
"productForm_interactions": "Interakcje",
"productForm_synergizesWith": "Synergizuje z (jedno na linię)",
"productForm_incompatibleWith": "Niekompatybilny z",
"productForm_addIncompatibility": "+ Dodaj niekompatybilność",
"productForm_noIncompatibilities": "Brak niekompatybilności.",
"productForm_incompTarget": "Składnik docelowy",
"productForm_incompScope": "Zakres",
"productForm_incompReason": "Powód (opcjonalny)",
"productForm_incompReasonPlaceholder": "np. zmniejsza skuteczność",
"productForm_incompScopeSelect": "Wybierz…",
"productForm_contextRules": "Reguły kontekstu",
"productForm_ctxAfterShaving": "Bezpieczny po goleniu",
"productForm_ctxAfterAcids": "Bezpieczny po kwasach",
"productForm_ctxAfterRetinoids": "Bezpieczny po retinoidach",
"productForm_ctxCompromisedBarrier": "Bezpieczny przy naruszonej barierze",
"productForm_ctxLowUvOnly": "Tylko przy niskim UV (wieczór/zakrycie)",
"productForm_productDetails": "Szczegóły produktu",
"productForm_priceTier": "Przedział cenowy",
"productForm_selectTier": "Wybierz przedział",
"productForm_sizeMl": "Rozmiar (ml)",
"productForm_fullWeightG": "Waga pełna (g)",
"productForm_emptyWeightG": "Waga pustego (g)",
"productForm_paoMonths": "PAO (miesiące)",
"productForm_phMin": "pH min",
"productForm_phMax": "pH max",
"productForm_usageNotes": "Notatki o stosowaniu",
"productForm_usageNotesPlaceholder": "np. Nakładaj na wilgotną skórę, unikaj okolic oczu",
"productForm_safetyFlags": "Flagi bezpieczeństwa",
"productForm_fragranceFree": "Bez zapachów",
"productForm_essentialOilsFree": "Bez olejków eterycznych",
"productForm_alcoholDenatFree": "Bez alkoholu denat.",
"productForm_pregnancySafe": "Bezpieczny w ciąży",
"productForm_usageConstraints": "Ograniczenia stosowania",
"productForm_minIntervalHours": "Min. przerwa (godziny)",
"productForm_maxFrequencyPerWeek": "Max użyć na tydzień",
"productForm_isMedication": "To lek",
"productForm_isTool": "To narzędzie (np. dermaroller)",
"productForm_needleLengthMm": "Długość igły (mm, tylko narzędzia)",
"productForm_personalNotes": "Notatki osobiste",
"productForm_repurchaseIntent": "Zamiar ponownego zakupu",
"productForm_toleranceNotes": "Notatki o tolerancji",
"productForm_toleranceNotesPlaceholder": "np. Lekkie pieczenie, ustępuje po 2 tygodniach",
"productForm_aiPrefill": "Uzupełnienie AI",
"productForm_aiPrefillText": "Wklej opis produktu ze strony, listę składników lub inny tekst. AI uzupełni dostępne pola — możesz je przejrzeć i poprawić przed zapisem.",
"productForm_pasteText": "Wklej tutaj opis produktu, składniki INCI...",
"productForm_parseWithAI": "Uzupełnij pola (AI)",
"productForm_parsing": "Przetwarzam…",
"productForm_basicInfo": "Informacje podstawowe",
"productForm_name": "Nazwa *",
"productForm_namePlaceholder": "np. Hydro Boost Water Gel",
"productForm_brand": "Marka *",
"productForm_brandPlaceholder": "np. Neutrogena",
"productForm_lineName": "Linia / seria",
"productForm_lineNamePlaceholder": "np. Hydro Boost",
"productForm_url": "URL",
"productForm_sku": "SKU",
"productForm_skuPlaceholder": "np. NTR-HB-50",
"productForm_barcode": "Kod kreskowy / EAN",
"productForm_barcodePlaceholder": "np. 3614273258975",
"productForm_classification": "Klasyfikacja",
"productForm_category": "Kategoria *",
"productForm_selectCategory": "Wybierz kategorię",
"productForm_time": "Pora *",
"productForm_timeOptions": "AM / PM / Oba",
"productForm_timeBoth": "Oba",
"productForm_leaveOn": "Leave-on *",
"productForm_leaveOnYes": "Tak (leave-on)",
"productForm_leaveOnNo": "Nie (rinse-off)",
"productForm_texture": "Tekstura",
"productForm_selectTexture": "Wybierz teksturę",
"productForm_absorptionSpeed": "Szybkość wchłaniania",
"productForm_selectSpeed": "Wybierz szybkość",
"productForm_skinProfile": "Profil skóry",
"productForm_recommendedFor": "Polecane dla typów skóry",
"productForm_targetConcerns": "Problemy docelowe",
"productForm_contraindications": "Przeciwwskazania (jedno na linię)",
"productForm_contraindicationsPlaceholder": "np. aktywna rosacea",
"productForm_ingredients": "Składniki",
"productForm_inciList": "Lista INCI (jeden składnik na linię)",
"productForm_activeIngredients": "Składniki aktywne",
"productForm_addActive": "+ Dodaj aktywny",
"productForm_noActives": "Brak składników aktywnych.",
"productForm_activeName": "Nazwa",
"productForm_activePercent": "%",
"productForm_activeStrength": "Siła",
"productForm_activeIrritation": "Podrażnienie",
"productForm_activeFunctions": "Funkcje",
"productForm_effectProfile": "Profil działania (05)",
"productForm_interactions": "Interakcje",
"productForm_synergizesWith": "Synergizuje z (jedno na linię)",
"productForm_incompatibleWith": "Niekompatybilny z",
"productForm_addIncompatibility": "+ Dodaj niekompatybilność",
"productForm_noIncompatibilities": "Brak niekompatybilności.",
"productForm_incompTarget": "Składnik docelowy",
"productForm_incompScope": "Zakres",
"productForm_incompReason": "Powód (opcjonalny)",
"productForm_incompReasonPlaceholder": "np. zmniejsza skuteczność",
"productForm_incompScopeSelect": "Wybierz…",
"productForm_contextRules": "Reguły kontekstu",
"productForm_ctxAfterShaving": "Bezpieczny po goleniu",
"productForm_ctxAfterAcids": "Bezpieczny po kwasach",
"productForm_ctxAfterRetinoids": "Bezpieczny po retinoidach",
"productForm_ctxCompromisedBarrier": "Bezpieczny przy naruszonej barierze",
"productForm_ctxLowUvOnly": "Tylko przy niskim UV (wieczór/zakrycie)",
"productForm_productDetails": "Szczegóły produktu",
"productForm_priceTier": "Przedział cenowy",
"productForm_selectTier": "Wybierz przedział",
"productForm_sizeMl": "Rozmiar (ml)",
"productForm_fullWeightG": "Waga pełna (g)",
"productForm_emptyWeightG": "Waga pustego (g)",
"productForm_paoMonths": "PAO (miesiące)",
"productForm_phMin": "pH min",
"productForm_phMax": "pH max",
"productForm_usageNotes": "Notatki o stosowaniu",
"productForm_usageNotesPlaceholder": "np. Nakładaj na wilgotną skórę, unikaj okolic oczu",
"productForm_safetyFlags": "Flagi bezpieczeństwa",
"productForm_fragranceFree": "Bez zapachów",
"productForm_essentialOilsFree": "Bez olejków eterycznych",
"productForm_alcoholDenatFree": "Bez alkoholu denat.",
"productForm_pregnancySafe": "Bezpieczny w ciąży",
"productForm_usageConstraints": "Ograniczenia stosowania",
"productForm_minIntervalHours": "Min. przerwa (godziny)",
"productForm_maxFrequencyPerWeek": "Max użyć na tydzień",
"productForm_isMedication": "To lek",
"productForm_isTool": "To narzędzie (np. dermaroller)",
"productForm_needleLengthMm": "Długość igły (mm, tylko narzędzia)",
"productForm_personalNotes": "Notatki osobiste",
"productForm_repurchaseIntent": "Zamiar ponownego zakupu",
"productForm_toleranceNotes": "Notatki o tolerancji",
"productForm_toleranceNotesPlaceholder": "np. Lekkie pieczenie, ustępuje po 2 tygodniach",
"productForm_categoryCleanser": "Żel/pianka do mycia",
"productForm_categoryToner": "Tonik",
"productForm_categoryEssence": "Esencja",
"productForm_categorySerum": "Serum",
"productForm_categoryMoisturizer": "Krem",
"productForm_categorySpf": "SPF",
"productForm_categoryMask": "Maska",
"productForm_categoryExfoliant": "Peeling",
"productForm_categoryHairTreatment": "Pielęgnacja włosów",
"productForm_categoryTool": "Narzędzie",
"productForm_categorySpotTreatment": "Punkt leczenia",
"productForm_categoryOil": "Olejek",
"productForm_categoryCleanser": "Żel/pianka do mycia",
"productForm_categoryToner": "Tonik",
"productForm_categoryEssence": "Esencja",
"productForm_categorySerum": "Serum",
"productForm_categoryMoisturizer": "Krem",
"productForm_categorySpf": "SPF",
"productForm_categoryMask": "Maska",
"productForm_categoryExfoliant": "Peeling",
"productForm_categoryHairTreatment": "Pielęgnacja włosów",
"productForm_categoryTool": "Narzędzie",
"productForm_categorySpotTreatment": "Punkt leczenia",
"productForm_categoryOil": "Olejek",
"productForm_textureWatery": "Wodnista",
"productForm_textureGel": "Żel",
"productForm_textureEmulsion": "Emulsja",
"productForm_textureCream": "Krem",
"productForm_textureOil": "Olejek",
"productForm_textureBalm": "Balsam",
"productForm_textureFoam": "Pianka",
"productForm_textureFluid": "Fluid",
"productForm_textureWatery": "Wodnista",
"productForm_textureGel": "Żel",
"productForm_textureEmulsion": "Emulsja",
"productForm_textureCream": "Krem",
"productForm_textureOil": "Olejek",
"productForm_textureBalm": "Balsam",
"productForm_textureFoam": "Pianka",
"productForm_textureFluid": "Fluid",
"productForm_absorptionVeryFast": "Bardzo szybkie",
"productForm_absorptionFast": "Szybkie",
"productForm_absorptionModerate": "Umiarkowane",
"productForm_absorptionSlow": "Wolne",
"productForm_absorptionVerySlow": "Bardzo wolne",
"productForm_absorptionVeryFast": "Bardzo szybkie",
"productForm_absorptionFast": "Szybkie",
"productForm_absorptionModerate": "Umiarkowane",
"productForm_absorptionSlow": "Wolne",
"productForm_absorptionVerySlow": "Bardzo wolne",
"productForm_priceBudget": "Budżetowy",
"productForm_priceMid": "Średni",
"productForm_pricePremium": "Premium",
"productForm_priceLuxury": "Luksusowy",
"productForm_priceBudget": "Budżetowy",
"productForm_priceMid": "Średni",
"productForm_pricePremium": "Premium",
"productForm_priceLuxury": "Luksusowy",
"productForm_skinTypeDry": "sucha",
"productForm_skinTypeOily": "tłusta",
"productForm_skinTypeCombination": "mieszana",
"productForm_skinTypeSensitive": "wrażliwa",
"productForm_skinTypeNormal": "normalna",
"productForm_skinTypeAcneProne": "trądzikowa",
"productForm_skinTypeDry": "sucha",
"productForm_skinTypeOily": "tłusta",
"productForm_skinTypeCombination": "mieszana",
"productForm_skinTypeSensitive": "wrażliwa",
"productForm_skinTypeNormal": "normalna",
"productForm_skinTypeAcneProne": "trądzikowa",
"productForm_concernAcne": "trądzik",
"productForm_concernRosacea": "rosacea",
"productForm_concernHyperpigmentation": "przebarwienia",
"productForm_concernAging": "starzenie",
"productForm_concernDehydration": "odwodnienie",
"productForm_concernRedness": "zaczerwienienie",
"productForm_concernDamagedBarrier": "naruszona bariera",
"productForm_concernPoreVisibility": "widoczność porów",
"productForm_concernUnevenTexture": "nierówna tekstura",
"productForm_concernHairGrowth": "wzrost włosów",
"productForm_concernSebumExcess": "nadmiar sebum",
"productForm_concernAcne": "trądzik",
"productForm_concernRosacea": "rosacea",
"productForm_concernHyperpigmentation": "przebarwienia",
"productForm_concernAging": "starzenie",
"productForm_concernDehydration": "odwodnienie",
"productForm_concernRedness": "zaczerwienienie",
"productForm_concernDamagedBarrier": "naruszona bariera",
"productForm_concernPoreVisibility": "widoczność porów",
"productForm_concernUnevenTexture": "nierówna tekstura",
"productForm_concernHairGrowth": "wzrost włosów",
"productForm_concernSebumExcess": "nadmiar sebum",
"productForm_fnHumectant": "humektant",
"productForm_fnEmollient": "emolient",
"productForm_fnOcclusive": "okluzja",
"productForm_fnExfoliantAha": "peeling AHA",
"productForm_fnExfoliantBha": "peeling BHA",
"productForm_fnExfoliantPha": "peeling PHA",
"productForm_fnRetinoid": "retinoid",
"productForm_fnAntioxidant": "antyoksydant",
"productForm_fnSoothing": "łagodzący",
"productForm_fnBarrierSupport": "wsparcie bariery",
"productForm_fnBrightening": "rozjaśniający",
"productForm_fnAntiAcne": "przeciwtrądzikowy",
"productForm_fnCeramide": "ceramid",
"productForm_fnNiacinamide": "niacynamid",
"productForm_fnSunscreen": "filtr UV",
"productForm_fnPeptide": "peptyd",
"productForm_fnHairGrowth": "stymulator wzrostu włosów",
"productForm_fnPrebiotic": "prebiotyk",
"productForm_fnVitaminC": "witamina C",
"productForm_fnAntiAging": "przeciwstarzeniowy",
"productForm_fnHumectant": "humektant",
"productForm_fnEmollient": "emolient",
"productForm_fnOcclusive": "okluzja",
"productForm_fnExfoliantAha": "peeling AHA",
"productForm_fnExfoliantBha": "peeling BHA",
"productForm_fnExfoliantPha": "peeling PHA",
"productForm_fnRetinoid": "retinoid",
"productForm_fnAntioxidant": "antyoksydant",
"productForm_fnSoothing": "łagodzący",
"productForm_fnBarrierSupport": "wsparcie bariery",
"productForm_fnBrightening": "rozjaśniający",
"productForm_fnAntiAcne": "przeciwtrądzikowy",
"productForm_fnCeramide": "ceramid",
"productForm_fnNiacinamide": "niacynamid",
"productForm_fnSunscreen": "filtr UV",
"productForm_fnPeptide": "peptyd",
"productForm_fnHairGrowth": "stymulator wzrostu włosów",
"productForm_fnPrebiotic": "prebiotyk",
"productForm_fnVitaminC": "witamina C",
"productForm_fnAntiAging": "przeciwstarzeniowy",
"productForm_scopeSameStep": "ten sam krok",
"productForm_scopeSameDay": "ten sam dzień",
"productForm_scopeSamePeriod": "ten sam okres",
"productForm_scopeSameStep": "ten sam krok",
"productForm_scopeSameDay": "ten sam dzień",
"productForm_scopeSamePeriod": "ten sam okres",
"productForm_strengthLow": "1 Niskie",
"productForm_strengthMedium": "2 Średnie",
"productForm_strengthHigh": "3 Wysokie",
"productForm_strengthLow": "1 Niskie",
"productForm_strengthMedium": "2 Średnie",
"productForm_strengthHigh": "3 Wysokie",
"productForm_effectHydrationImmediate": "Nawilżenie (natychmiastowe)",
"productForm_effectHydrationLongTerm": "Nawilżenie (długoterminowe)",
"productForm_effectBarrierRepair": "Naprawa bariery",
"productForm_effectSoothing": "Łagodzenie",
"productForm_effectExfoliation": "Złuszczanie",
"productForm_effectRetinoid": "Aktywność retinoidu",
"productForm_effectIrritation": "Ryzyko podrażnienia",
"productForm_effectComedogenic": "Ryzyko komedogenności",
"productForm_effectBarrierDisruption": "Ryzyko naruszenia bariery",
"productForm_effectDryness": "Ryzyko przesuszenia",
"productForm_effectBrightening": "Rozjaśnienie",
"productForm_effectAntiAcne": "Działanie przeciwtrądzikowe",
"productForm_effectAntiAging": "Działanie przeciwstarzeniowe",
"productForm_effectHydrationImmediate": "Nawilżenie (natychmiastowe)",
"productForm_effectHydrationLongTerm": "Nawilżenie (długoterminowe)",
"productForm_effectBarrierRepair": "Naprawa bariery",
"productForm_effectSoothing": "Łagodzenie",
"productForm_effectExfoliation": "Złuszczanie",
"productForm_effectRetinoid": "Aktywność retinoidu",
"productForm_effectIrritation": "Ryzyko podrażnienia",
"productForm_effectComedogenic": "Ryzyko komedogenności",
"productForm_effectBarrierDisruption": "Ryzyko naruszenia bariery",
"productForm_effectDryness": "Ryzyko przesuszenia",
"productForm_effectBrightening": "Rozjaśnienie",
"productForm_effectAntiAcne": "Działanie przeciwtrądzikowe",
"productForm_effectAntiAging": "Działanie przeciwstarzeniowe",
"lang_pl": "PL",
"lang_en": "EN"
"lang_pl": "PL",
"lang_en": "EN"
}

View file

@ -1,36 +1,47 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@internationalized/date": "^3.11.0",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.1",
"svelte": "^5.51.0",
"svelte-check": "^4.3.6",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"@inlang/paraglide-js": "^2.13.0",
"bits-ui": "^2.16.2",
"clsx": "^2.1.1",
"lucide-svelte": "^0.575.0",
"mode-watcher": "^1.1.0",
"tailwind-merge": "^3.5.0"
}
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@internationalized/date": "^3.11.0",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.1",
"eslint": "^10.0.2",
"eslint-plugin-svelte": "^3.15.0",
"globals": "^17.4.0",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.0",
"svelte": "^5.51.0",
"svelte-check": "^4.3.6",
"svelte-eslint-parser": "^1.5.1",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"
},
"dependencies": {
"@inlang/paraglide-js": "^2.13.0",
"bits-ui": "^2.16.2",
"clsx": "^2.1.1",
"lucide-svelte": "^0.575.0",
"mode-watcher": "^1.1.0",
"svelte-dnd-action": "^0.9.69",
"tailwind-merge": "^3.5.0"
}
}

3149
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,12 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "pl",
"locales": ["pl", "en"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "pl",
"locales": ["pl", "en"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
}

View file

@ -5,85 +5,85 @@
/* ── CSS variable definitions (light / dark) ─────────────────────────────── */
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%);
--primary: hsl(240 5.9% 10%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
--ring: hsl(240 5.9% 10%);
--radius: 0.5rem;
--background: hsl(0 0% 100%);
--foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%);
--primary: hsl(240 5.9% 10%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
--ring: hsl(240 5.9% 10%);
--radius: 0.5rem;
}
.dark {
--background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(240 10% 3.9%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(240 10% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%);
--accent: hsl(240 3.7% 15.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%);
--ring: hsl(240 4.9% 83.9%);
--background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(240 10% 3.9%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(240 10% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%);
--accent: hsl(240 3.7% 15.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%);
--ring: hsl(240 4.9% 83.9%);
}
/* ── Map CSS vars → Tailwind v4 design tokens ────────────────────────────── */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
/* ── Base resets ─────────────────────────────────────────────────────────── */
* {
border-color: var(--border);
border-color: var(--border);
}
body {
background-color: var(--background);
color: var(--foreground);
background-color: var(--background);
color: var(--foreground);
}

14
frontend/src/app.d.ts vendored
View file

@ -1,13 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View file

@ -1,11 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -1,6 +1,6 @@
import { paraglideMiddleware } from '$lib/paraglide/server.js';
import type { Handle } from '@sveltejs/kit';
import { paraglideMiddleware } from "$lib/paraglide/server.js";
import type { Handle } from "@sveltejs/kit";
export const handle: Handle = async ({ event, resolve }) => {
return paraglideMiddleware(event.request, () => resolve(event));
return paraglideMiddleware(event.request, () => resolve(event));
};

View file

@ -1,271 +1,349 @@
import { PUBLIC_API_BASE } from '$env/static/public';
import { browser } from "$app/environment";
import { PUBLIC_API_BASE } from "$env/static/public";
import type {
ActiveIngredient,
BatchSuggestion,
GroomingSchedule,
LabResult,
MedicationEntry,
MedicationUsage,
PartOfDay,
Product,
ProductContext,
ProductEffectProfile,
ProductInteraction,
ProductInventory,
Routine,
RoutineSuggestion,
RoutineStep,
SkinConditionSnapshot
} from './types';
ActiveIngredient,
BatchSuggestion,
GroomingSchedule,
LabResult,
MedicationEntry,
MedicationUsage,
PartOfDay,
Product,
ProductContext,
ProductEffectProfile,
ProductInteraction,
ProductInventory,
Routine,
RoutineSuggestion,
RoutineStep,
SkinConditionSnapshot,
} from "./types";
// ─── Core fetch helpers ──────────────────────────────────────────────────────
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const url = `${PUBLIC_API_BASE}${path}`;
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...init.headers },
...init
});
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(detail?.detail ?? res.statusText);
}
if (res.status === 204) return undefined as T;
return res.json();
// Server-side uses PUBLIC_API_BASE (e.g. http://localhost:8000).
// Browser-side uses /api so nginx proxies the request on the correct host.
const base = browser ? "/api" : PUBLIC_API_BASE;
const url = `${base}${path}`;
const res = await fetch(url, {
headers: { "Content-Type": "application/json", ...init.headers },
...init,
});
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(detail?.detail ?? res.statusText);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
del: (path: string) => request<void>(path, { method: 'DELETE' })
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
del: (path: string) => request<void>(path, { method: "DELETE" }),
};
// ─── Products ────────────────────────────────────────────────────────────────
export interface ProductListParams {
category?: string;
brand?: string;
targets?: string[];
is_medication?: boolean;
is_tool?: boolean;
category?: string;
brand?: string;
targets?: string[];
is_medication?: boolean;
is_tool?: boolean;
}
export function getProducts(params: ProductListParams = {}): Promise<Product[]> {
const q = new URLSearchParams();
if (params.category) q.set('category', params.category);
if (params.brand) q.set('brand', params.brand);
if (params.targets) params.targets.forEach((t) => q.append('targets', t));
if (params.is_medication != null) q.set('is_medication', String(params.is_medication));
if (params.is_tool != null) q.set('is_tool', String(params.is_tool));
const qs = q.toString();
return api.get(`/products${qs ? `?${qs}` : ''}`);
export function getProducts(
params: ProductListParams = {},
): Promise<Product[]> {
const q = new URLSearchParams();
if (params.category) q.set("category", params.category);
if (params.brand) q.set("brand", params.brand);
if (params.targets) params.targets.forEach((t) => q.append("targets", t));
if (params.is_medication != null)
q.set("is_medication", String(params.is_medication));
if (params.is_tool != null) q.set("is_tool", String(params.is_tool));
const qs = q.toString();
return api.get(`/products${qs ? `?${qs}` : ""}`);
}
export const getProduct = (id: string): Promise<Product> => api.get(`/products/${id}`);
export const createProduct = (body: Record<string, unknown>): Promise<Product> =>
api.post('/products', body);
export const updateProduct = (id: string, body: Record<string, unknown>): Promise<Product> =>
api.patch(`/products/${id}`, body);
export const deleteProduct = (id: string): Promise<void> => api.del(`/products/${id}`);
export const getProduct = (id: string): Promise<Product> =>
api.get(`/products/${id}`);
export const createProduct = (
body: Record<string, unknown>,
): Promise<Product> => api.post("/products", body);
export const updateProduct = (
id: string,
body: Record<string, unknown>,
): Promise<Product> => api.patch(`/products/${id}`, body);
export const deleteProduct = (id: string): Promise<void> =>
api.del(`/products/${id}`);
export const getInventory = (productId: string): Promise<ProductInventory[]> =>
api.get(`/products/${productId}/inventory`);
api.get(`/products/${productId}/inventory`);
export const createInventory = (
productId: string,
body: Record<string, unknown>
): Promise<ProductInventory> => api.post(`/products/${productId}/inventory`, body);
export const updateInventory = (id: string, body: Record<string, unknown>): Promise<ProductInventory> =>
api.patch(`/inventory/${id}`, body);
export const deleteInventory = (id: string): Promise<void> => api.del(`/inventory/${id}`);
productId: string,
body: Record<string, unknown>,
): Promise<ProductInventory> =>
api.post(`/products/${productId}/inventory`, body);
export const updateInventory = (
id: string,
body: Record<string, unknown>,
): Promise<ProductInventory> => api.patch(`/inventory/${id}`, body);
export const deleteInventory = (id: string): Promise<void> =>
api.del(`/inventory/${id}`);
export interface ProductParseResponse {
name?: string; brand?: string; line_name?: string; sku?: string; url?: string; barcode?: string;
category?: string; recommended_time?: string; texture?: string; absorption_speed?: string;
leave_on?: boolean; price_tier?: string;
size_ml?: number; full_weight_g?: number; empty_weight_g?: number; pao_months?: number;
inci?: string[]; actives?: ActiveIngredient[];
recommended_for?: string[]; targets?: string[];
contraindications?: string[]; usage_notes?: string;
fragrance_free?: boolean; essential_oils_free?: boolean;
alcohol_denat_free?: boolean; pregnancy_safe?: boolean;
product_effect_profile?: ProductEffectProfile;
ph_min?: number; ph_max?: number;
incompatible_with?: ProductInteraction[]; synergizes_with?: string[];
context_rules?: ProductContext;
min_interval_hours?: number; max_frequency_per_week?: number;
is_medication?: boolean; is_tool?: boolean; needle_length_mm?: number;
name?: string;
brand?: string;
line_name?: string;
sku?: string;
url?: string;
barcode?: string;
category?: string;
recommended_time?: string;
texture?: string;
absorption_speed?: string;
leave_on?: boolean;
price_tier?: string;
size_ml?: number;
full_weight_g?: number;
empty_weight_g?: number;
pao_months?: number;
inci?: string[];
actives?: ActiveIngredient[];
recommended_for?: string[];
targets?: string[];
contraindications?: string[];
usage_notes?: string;
fragrance_free?: boolean;
essential_oils_free?: boolean;
alcohol_denat_free?: boolean;
pregnancy_safe?: boolean;
product_effect_profile?: ProductEffectProfile;
ph_min?: number;
ph_max?: number;
incompatible_with?: ProductInteraction[];
synergizes_with?: string[];
context_rules?: ProductContext;
min_interval_hours?: number;
max_frequency_per_week?: number;
is_medication?: boolean;
is_tool?: boolean;
needle_length_mm?: number;
}
export const parseProductText = (text: string): Promise<ProductParseResponse> =>
api.post('/products/parse-text', { text });
api.post("/products/parse-text", { text });
// ─── Routines ────────────────────────────────────────────────────────────────
export interface RoutineListParams {
from_date?: string;
to_date?: string;
part_of_day?: string;
from_date?: string;
to_date?: string;
part_of_day?: string;
}
export function getRoutines(params: RoutineListParams = {}): Promise<Routine[]> {
const q = new URLSearchParams();
if (params.from_date) q.set('from_date', params.from_date);
if (params.to_date) q.set('to_date', params.to_date);
if (params.part_of_day) q.set('part_of_day', params.part_of_day);
const qs = q.toString();
return api.get(`/routines${qs ? `?${qs}` : ''}`);
export function getRoutines(
params: RoutineListParams = {},
): Promise<Routine[]> {
const q = new URLSearchParams();
if (params.from_date) q.set("from_date", params.from_date);
if (params.to_date) q.set("to_date", params.to_date);
if (params.part_of_day) q.set("part_of_day", params.part_of_day);
const qs = q.toString();
return api.get(`/routines${qs ? `?${qs}` : ""}`);
}
export const getRoutine = (id: string): Promise<Routine> => api.get(`/routines/${id}`);
export const createRoutine = (body: Record<string, unknown>): Promise<Routine> =>
api.post('/routines', body);
export const updateRoutine = (id: string, body: Record<string, unknown>): Promise<Routine> =>
api.patch(`/routines/${id}`, body);
export const deleteRoutine = (id: string): Promise<void> => api.del(`/routines/${id}`);
export const getRoutine = (id: string): Promise<Routine> =>
api.get(`/routines/${id}`);
export const createRoutine = (
body: Record<string, unknown>,
): Promise<Routine> => api.post("/routines", body);
export const updateRoutine = (
id: string,
body: Record<string, unknown>,
): Promise<Routine> => api.patch(`/routines/${id}`, body);
export const deleteRoutine = (id: string): Promise<void> =>
api.del(`/routines/${id}`);
export const addRoutineStep = (routineId: string, body: Record<string, unknown>): Promise<RoutineStep> =>
api.post(`/routines/${routineId}/steps`, body);
export const updateRoutineStep = (stepId: string, body: Record<string, unknown>): Promise<RoutineStep> =>
api.patch(`/routines/steps/${stepId}`, body);
export const addRoutineStep = (
routineId: string,
body: Record<string, unknown>,
): Promise<RoutineStep> => api.post(`/routines/${routineId}/steps`, body);
export const updateRoutineStep = (
stepId: string,
body: Record<string, unknown>,
): Promise<RoutineStep> => api.patch(`/routines/steps/${stepId}`, body);
export const deleteRoutineStep = (stepId: string): Promise<void> =>
api.del(`/routines/steps/${stepId}`);
api.del(`/routines/steps/${stepId}`);
export const suggestRoutine = (body: {
routine_date: string;
part_of_day: PartOfDay;
notes?: string;
}): Promise<RoutineSuggestion> => api.post('/routines/suggest', body);
routine_date: string;
part_of_day: PartOfDay;
notes?: string;
include_minoxidil_beard?: boolean;
leaving_home?: boolean;
}): Promise<RoutineSuggestion> => api.post("/routines/suggest", body);
export const suggestBatch = (body: {
from_date: string;
to_date: string;
notes?: string;
}): Promise<BatchSuggestion> => api.post('/routines/suggest-batch', body);
from_date: string;
to_date: string;
notes?: string;
include_minoxidil_beard?: boolean;
minimize_products?: boolean;
}): Promise<BatchSuggestion> => api.post("/routines/suggest-batch", body);
export const getGroomingSchedule = (): Promise<GroomingSchedule[]> =>
api.get('/routines/grooming-schedule');
export const createGroomingScheduleEntry = (body: Record<string, unknown>): Promise<GroomingSchedule> =>
api.post('/routines/grooming-schedule', body);
export const updateGroomingScheduleEntry = (id: string, body: Record<string, unknown>): Promise<GroomingSchedule> =>
api.patch(`/routines/grooming-schedule/${id}`, body);
api.get("/routines/grooming-schedule");
export const createGroomingScheduleEntry = (
body: Record<string, unknown>,
): Promise<GroomingSchedule> => api.post("/routines/grooming-schedule", body);
export const updateGroomingScheduleEntry = (
id: string,
body: Record<string, unknown>,
): Promise<GroomingSchedule> =>
api.patch(`/routines/grooming-schedule/${id}`, body);
export const deleteGroomingScheduleEntry = (id: string): Promise<void> =>
api.del(`/routines/grooming-schedule/${id}`);
api.del(`/routines/grooming-schedule/${id}`);
// ─── Health Medications ────────────────────────────────────────────────────
export interface MedicationListParams {
kind?: string;
product_name?: string;
kind?: string;
product_name?: string;
}
export function getMedications(params: MedicationListParams = {}): Promise<MedicationEntry[]> {
const q = new URLSearchParams();
if (params.kind) q.set('kind', params.kind);
if (params.product_name) q.set('product_name', params.product_name);
const qs = q.toString();
return api.get(`/health/medications${qs ? `?${qs}` : ''}`);
export function getMedications(
params: MedicationListParams = {},
): Promise<MedicationEntry[]> {
const q = new URLSearchParams();
if (params.kind) q.set("kind", params.kind);
if (params.product_name) q.set("product_name", params.product_name);
const qs = q.toString();
return api.get(`/health/medications${qs ? `?${qs}` : ""}`);
}
export const getMedication = (id: string): Promise<MedicationEntry> =>
api.get(`/health/medications/${id}`);
export const createMedication = (body: Record<string, unknown>): Promise<MedicationEntry> =>
api.post('/health/medications', body);
api.get(`/health/medications/${id}`);
export const createMedication = (
body: Record<string, unknown>,
): Promise<MedicationEntry> => api.post("/health/medications", body);
export const updateMedication = (
id: string,
body: Record<string, unknown>
id: string,
body: Record<string, unknown>,
): Promise<MedicationEntry> => api.patch(`/health/medications/${id}`, body);
export const deleteMedication = (id: string): Promise<void> =>
api.del(`/health/medications/${id}`);
api.del(`/health/medications/${id}`);
export const getMedicationUsages = (medicationId: string): Promise<MedicationUsage[]> =>
api.get(`/health/medications/${medicationId}/usages`);
export const getMedicationUsages = (
medicationId: string,
): Promise<MedicationUsage[]> =>
api.get(`/health/medications/${medicationId}/usages`);
export const createMedicationUsage = (
medicationId: string,
body: Record<string, unknown>
): Promise<MedicationUsage> => api.post(`/health/medications/${medicationId}/usages`, body);
medicationId: string,
body: Record<string, unknown>,
): Promise<MedicationUsage> =>
api.post(`/health/medications/${medicationId}/usages`, body);
// ─── Health Lab results ────────────────────────────────────────────────────
export interface LabResultListParams {
test_code?: string;
flag?: string;
lab?: string;
from_date?: string;
to_date?: string;
test_code?: string;
flag?: string;
lab?: string;
from_date?: string;
to_date?: string;
}
export function getLabResults(params: LabResultListParams = {}): Promise<LabResult[]> {
const q = new URLSearchParams();
if (params.test_code) q.set('test_code', params.test_code);
if (params.flag) q.set('flag', params.flag);
if (params.lab) q.set('lab', params.lab);
if (params.from_date) q.set('from_date', params.from_date);
if (params.to_date) q.set('to_date', params.to_date);
const qs = q.toString();
return api.get(`/health/lab-results${qs ? `?${qs}` : ''}`);
export function getLabResults(
params: LabResultListParams = {},
): Promise<LabResult[]> {
const q = new URLSearchParams();
if (params.test_code) q.set("test_code", params.test_code);
if (params.flag) q.set("flag", params.flag);
if (params.lab) q.set("lab", params.lab);
if (params.from_date) q.set("from_date", params.from_date);
if (params.to_date) q.set("to_date", params.to_date);
const qs = q.toString();
return api.get(`/health/lab-results${qs ? `?${qs}` : ""}`);
}
export const getLabResult = (id: string): Promise<LabResult> =>
api.get(`/health/lab-results/${id}`);
export const createLabResult = (body: Record<string, unknown>): Promise<LabResult> =>
api.post('/health/lab-results', body);
export const updateLabResult = (id: string, body: Record<string, unknown>): Promise<LabResult> =>
api.patch(`/health/lab-results/${id}`, body);
api.get(`/health/lab-results/${id}`);
export const createLabResult = (
body: Record<string, unknown>,
): Promise<LabResult> => api.post("/health/lab-results", body);
export const updateLabResult = (
id: string,
body: Record<string, unknown>,
): Promise<LabResult> => api.patch(`/health/lab-results/${id}`, body);
export const deleteLabResult = (id: string): Promise<void> =>
api.del(`/health/lab-results/${id}`);
api.del(`/health/lab-results/${id}`);
// ─── Skin ────────────────────────────────────────────────────────────────────
export interface SnapshotListParams {
from_date?: string;
to_date?: string;
overall_state?: string;
from_date?: string;
to_date?: string;
overall_state?: string;
}
export function getSkinSnapshots(params: SnapshotListParams = {}): Promise<SkinConditionSnapshot[]> {
const q = new URLSearchParams();
if (params.from_date) q.set('from_date', params.from_date);
if (params.to_date) q.set('to_date', params.to_date);
if (params.overall_state) q.set('overall_state', params.overall_state);
const qs = q.toString();
return api.get(`/skincare${qs ? `?${qs}` : ''}`);
export function getSkinSnapshots(
params: SnapshotListParams = {},
): Promise<SkinConditionSnapshot[]> {
const q = new URLSearchParams();
if (params.from_date) q.set("from_date", params.from_date);
if (params.to_date) q.set("to_date", params.to_date);
if (params.overall_state) q.set("overall_state", params.overall_state);
const qs = q.toString();
return api.get(`/skincare${qs ? `?${qs}` : ""}`);
}
export const getSkinSnapshot = (id: string): Promise<SkinConditionSnapshot> =>
api.get(`/skincare/${id}`);
export const createSkinSnapshot = (body: Record<string, unknown>): Promise<SkinConditionSnapshot> =>
api.post('/skincare', body);
api.get(`/skincare/${id}`);
export const createSkinSnapshot = (
body: Record<string, unknown>,
): Promise<SkinConditionSnapshot> => api.post("/skincare", body);
export const updateSkinSnapshot = (
id: string,
body: Record<string, unknown>
id: string,
body: Record<string, unknown>,
): Promise<SkinConditionSnapshot> => api.patch(`/skincare/${id}`, body);
export const deleteSkinSnapshot = (id: string): Promise<void> => api.del(`/skincare/${id}`);
export const deleteSkinSnapshot = (id: string): Promise<void> =>
api.del(`/skincare/${id}`);
export interface SkinPhotoAnalysisResponse {
overall_state?: string;
texture?: string;
skin_type?: string;
hydration_level?: number;
sebum_tzone?: number;
sebum_cheeks?: number;
sensitivity_level?: number;
barrier_state?: string;
active_concerns?: string[];
risks?: string[];
priorities?: string[];
notes?: string;
overall_state?: string;
texture?: string;
skin_type?: string;
hydration_level?: number;
sebum_tzone?: number;
sebum_cheeks?: number;
sensitivity_level?: number;
barrier_state?: string;
active_concerns?: string[];
risks?: string[];
priorities?: string[];
notes?: string;
}
export async function analyzeSkinPhotos(files: File[]): Promise<SkinPhotoAnalysisResponse> {
const body = new FormData();
for (const file of files) body.append('photos', file);
const res = await fetch(`${PUBLIC_API_BASE}/skincare/analyze-photos`, { method: 'POST', body });
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(detail?.detail ?? res.statusText);
}
return res.json();
export async function analyzeSkinPhotos(
files: File[],
): Promise<SkinPhotoAnalysisResponse> {
const body = new FormData();
for (const file of files) body.append("photos", file);
const base = browser ? "/api" : PUBLIC_API_BASE;
const res = await fetch(`${base}/skincare/analyze-photos`, {
method: "POST",
body,
});
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(detail?.detail ?? res.statusText);
}
return res.json();
}

View file

@ -451,7 +451,7 @@
<Card>
<CardHeader><CardTitle>{m["productForm_basicInfo"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="name">{m["productForm_name"]()}</Label>
<Input id="name" name="name" required placeholder={m["productForm_namePlaceholder"]()} bind:value={name} />
@ -461,7 +461,7 @@
<Input id="brand" name="brand" required placeholder={m["productForm_brandPlaceholder"]()} bind:value={brand} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="line_name">{m["productForm_lineName"]()}</Label>
<Input id="line_name" name="line_name" placeholder={m["productForm_lineNamePlaceholder"]()} bind:value={lineName} />
@ -471,7 +471,7 @@
<Input id="url" name="url" type="url" placeholder="https://…" bind:value={url} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="sku">{m["productForm_sku"]()}</Label>
<Input id="sku" name="sku" placeholder={m["productForm_skuPlaceholder"]()} bind:value={sku} />
@ -488,7 +488,7 @@
<Card>
<CardHeader><CardTitle>{m["productForm_classification"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="col-span-2 space-y-2">
<Label>{m["productForm_category"]()}</Label>
<input type="hidden" name="category" value={category} />
@ -564,7 +564,7 @@
<CardContent class="space-y-4">
<div class="space-y-2">
<Label>{m["productForm_recommendedFor"]()}</Label>
<div class="grid grid-cols-3 gap-2">
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
{#each skinTypes as st}
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input
@ -588,7 +588,7 @@
<div class="space-y-2">
<Label>{m["productForm_targetConcerns"]()}</Label>
<div class="grid grid-cols-3 gap-2">
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
{#each skinConcerns as sc}
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input
@ -641,7 +641,7 @@
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Label>{m["productForm_activeIngredients"]()}</Label>
<Button type="button" variant="outline" size="sm" onclick={addActive}>{m["productForm_addActive"]()}</Button>
</div>
@ -650,14 +650,23 @@
{#each actives as active, i}
<div class="rounded-md border border-border p-3 space-y-3">
<div class="grid grid-cols-[1fr_100px_120px_120px_auto] gap-2 items-end">
<div class="space-y-1">
<div class="flex items-end gap-2">
<div class="min-w-0 flex-1 space-y-1">
<Label class="text-xs">{m["productForm_activeName"]()}</Label>
<Input
placeholder="e.g. Niacinamide"
bind:value={active.name}
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => removeActive(i)}
class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive"
>✕</Button>
</div>
<div class="grid grid-cols-3 gap-2">
<div class="space-y-1">
<Label class="text-xs">{m["productForm_activePercent"]()}</Label>
<Input
@ -687,18 +696,11 @@
<option value="3">{m["productForm_strengthHigh"]()}</option>
</select>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => removeActive(i)}
class="text-destructive hover:text-destructive"
>✕</Button>
</div>
<div class="space-y-1">
<Label class="text-xs text-muted-foreground">{m["productForm_activeFunctions"]()}</Label>
<div class="grid grid-cols-4 gap-1">
<div class="grid grid-cols-2 gap-1 sm:grid-cols-4">
{#each ingFunctions as fn}
<label class="flex cursor-pointer items-center gap-1.5 text-xs">
<input
@ -764,7 +766,7 @@
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Label>{m["productForm_incompatibleWith"]()}</Label>
<Button type="button" variant="outline" size="sm" onclick={addIncompatible}>
{m["productForm_addIncompatibility"]()}
@ -774,7 +776,7 @@
<input type="hidden" name="incompatible_with_json" value={incompatibleJson} />
{#each incompatibleWith as row, i}
<div class="grid grid-cols-[1fr_140px_1fr_auto] gap-2 items-end">
<div class="grid grid-cols-2 gap-2 items-end sm:grid-cols-[1fr_140px_1fr_auto]">
<div class="space-y-1">
<Label class="text-xs">{m["productForm_incompTarget"]()}</Label>
<Input placeholder="e.g. Vitamin C" bind:value={row.target} />
@ -813,7 +815,7 @@
<Card>
<CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label>{m["productForm_ctxAfterShaving"]()}</Label>
<input type="hidden" name="ctx_safe_after_shaving" value={ctxAfterShaving} />
@ -884,7 +886,7 @@
<Card>
<CardHeader><CardTitle>{m["productForm_productDetails"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-3 gap-4">
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div class="space-y-2">
<Label>{m["productForm_priceTier"]()}</Label>
<input type="hidden" name="price_tier" value={priceTier} />
@ -919,7 +921,7 @@
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="ph_min">{m["productForm_phMin"]()}</Label>
<Input id="ph_min" name="ph_min" type="number" min="0" max="14" step="0.1" placeholder="e.g. 3.5" bind:value={phMin} />
@ -948,7 +950,7 @@
<Card>
<CardHeader><CardTitle>{m["productForm_safetyFlags"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label>{m["productForm_fragranceFree"]()}</Label>
<input type="hidden" name="fragrance_free" value={fragranceFree} />
@ -1008,7 +1010,7 @@
<Card>
<CardHeader><CardTitle>{m["productForm_usageConstraints"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="min_interval_hours">{m["productForm_minIntervalHours"]()}</Label>
<Input id="min_interval_hours" name="min_interval_hours" type="number" min="0" placeholder="e.g. 24" bind:value={minIntervalHours} />

View file

@ -1,17 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};

View file

@ -7,19 +7,19 @@ import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};

View file

@ -1,7 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
Root,
//
Root as Input,
};

View file

@ -1,7 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
Root,
//
Root as Label,
};

View file

@ -11,27 +11,27 @@ import GroupHeading from "./select-group-heading.svelte";
import Portal from "./select-portal.svelte";
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
Portal,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
Portal as SelectPortal,
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
Portal,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
Portal as SelectPortal,
};

View file

@ -1,7 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
Root,
//
Root as Separator,
};

View file

@ -8,21 +8,21 @@ import Header from "./table-header.svelte";
import Row from "./table-row.svelte";
export {
Root,
Body,
Caption,
Cell,
Footer,
Head,
Header,
Row,
//
Root as Table,
Body as TableBody,
Caption as TableCaption,
Cell as TableCell,
Footer as TableFooter,
Head as TableHead,
Header as TableHeader,
Row as TableRow,
Root,
Body,
Caption,
Cell,
Footer,
Head,
Header,
Row,
//
Root as Table,
Body as TableBody,
Caption as TableCaption,
Cell as TableCell,
Footer as TableFooter,
Head as TableHead,
Header as TableHeader,
Row as TableRow,
};

View file

@ -4,13 +4,13 @@ import List from "./tabs-list.svelte";
import Trigger from "./tabs-trigger.svelte";
export {
Root,
Content,
List,
Trigger,
//
Root as Tabs,
Content as TabsContent,
List as TabsList,
Trigger as TabsTrigger,
Root,
Content,
List,
Trigger,
//
Root as Tabs,
Content as TabsContent,
List as TabsList,
Trigger as TabsTrigger,
};

View file

@ -1,291 +1,344 @@
// ─── Enums ──────────────────────────────────────────────────────────────────
export type AbsorptionSpeed = 'very_fast' | 'fast' | 'moderate' | 'slow' | 'very_slow';
export type BarrierState = 'intact' | 'mildly_compromised' | 'compromised';
export type DayTime = 'am' | 'pm' | 'both';
export type GroomingAction = 'shaving_razor' | 'shaving_oneblade' | 'dermarolling';
export type AbsorptionSpeed =
| "very_fast"
| "fast"
| "moderate"
| "slow"
| "very_slow";
export type BarrierState = "intact" | "mildly_compromised" | "compromised";
export type DayTime = "am" | "pm" | "both";
export type GroomingAction =
| "shaving_razor"
| "shaving_oneblade"
| "dermarolling";
export type IngredientFunction =
| 'humectant'
| 'emollient'
| 'occlusive'
| 'exfoliant_aha'
| 'exfoliant_bha'
| 'exfoliant_pha'
| 'retinoid'
| 'antioxidant'
| 'soothing'
| 'barrier_support'
| 'brightening'
| 'anti_acne'
| 'ceramide'
| 'niacinamide'
| 'sunscreen'
| 'peptide'
| 'hair_growth_stimulant'
| 'prebiotic'
| 'vitamin_c'
| 'anti_aging';
export type InteractionScope = 'same_step' | 'same_day' | 'same_period';
export type MedicationKind = 'prescription' | 'otc' | 'supplement' | 'herbal' | 'other';
export type OverallSkinState = 'excellent' | 'good' | 'fair' | 'poor';
export type PartOfDay = 'am' | 'pm';
export type PriceTier = 'budget' | 'mid' | 'premium' | 'luxury';
| "humectant"
| "emollient"
| "occlusive"
| "exfoliant_aha"
| "exfoliant_bha"
| "exfoliant_pha"
| "retinoid"
| "antioxidant"
| "soothing"
| "barrier_support"
| "brightening"
| "anti_acne"
| "ceramide"
| "niacinamide"
| "sunscreen"
| "peptide"
| "hair_growth_stimulant"
| "prebiotic"
| "vitamin_c"
| "anti_aging";
export type InteractionScope = "same_step" | "same_day" | "same_period";
export type MedicationKind =
| "prescription"
| "otc"
| "supplement"
| "herbal"
| "other";
export type OverallSkinState = "excellent" | "good" | "fair" | "poor";
export type PartOfDay = "am" | "pm";
export type PriceTier = "budget" | "mid" | "premium" | "luxury";
export type ProductCategory =
| 'cleanser'
| 'toner'
| 'essence'
| 'serum'
| 'moisturizer'
| 'spf'
| 'mask'
| 'exfoliant'
| 'hair_treatment'
| 'tool'
| 'spot_treatment'
| 'oil';
export type ResultFlag = 'N' | 'ABN' | 'POS' | 'NEG' | 'L' | 'H';
| "cleanser"
| "toner"
| "essence"
| "serum"
| "moisturizer"
| "spf"
| "mask"
| "exfoliant"
| "hair_treatment"
| "tool"
| "spot_treatment"
| "oil";
export type ResultFlag = "N" | "ABN" | "POS" | "NEG" | "L" | "H";
export type SkinConcern =
| 'acne'
| 'rosacea'
| 'hyperpigmentation'
| 'aging'
| 'dehydration'
| 'redness'
| 'damaged_barrier'
| 'pore_visibility'
| 'uneven_texture'
| 'hair_growth'
| 'sebum_excess';
export type SkinTexture = 'smooth' | 'rough' | 'flaky' | 'bumpy';
export type SkinType = 'dry' | 'oily' | 'combination' | 'sensitive' | 'normal' | 'acne_prone';
| "acne"
| "rosacea"
| "hyperpigmentation"
| "aging"
| "dehydration"
| "redness"
| "damaged_barrier"
| "pore_visibility"
| "uneven_texture"
| "hair_growth"
| "sebum_excess";
export type SkinTexture = "smooth" | "rough" | "flaky" | "bumpy";
export type SkinType =
| "dry"
| "oily"
| "combination"
| "sensitive"
| "normal"
| "acne_prone";
export type StrengthLevel = 1 | 2 | 3;
export type TextureType = 'watery' | 'gel' | 'emulsion' | 'cream' | 'oil' | 'balm' | 'foam' | 'fluid';
export type TextureType =
| "watery"
| "gel"
| "emulsion"
| "cream"
| "oil"
| "balm"
| "foam"
| "fluid";
// ─── Product types ───────────────────────────────────────────────────────────
export interface ActiveIngredient {
name: string;
percent?: number;
functions: IngredientFunction[];
strength_level?: StrengthLevel;
irritation_potential?: StrengthLevel;
name: string;
percent?: number;
functions: IngredientFunction[];
strength_level?: StrengthLevel;
irritation_potential?: StrengthLevel;
}
export interface ProductEffectProfile {
hydration_immediate: number;
hydration_long_term: number;
barrier_repair_strength: number;
soothing_strength: number;
exfoliation_strength: number;
retinoid_strength: number;
irritation_risk: number;
comedogenic_risk: number;
barrier_disruption_risk: number;
dryness_risk: number;
brightening_strength: number;
anti_acne_strength: number;
anti_aging_strength: number;
hydration_immediate: number;
hydration_long_term: number;
barrier_repair_strength: number;
soothing_strength: number;
exfoliation_strength: number;
retinoid_strength: number;
irritation_risk: number;
comedogenic_risk: number;
barrier_disruption_risk: number;
dryness_risk: number;
brightening_strength: number;
anti_acne_strength: number;
anti_aging_strength: number;
}
export interface ProductInteraction {
target: string;
scope: InteractionScope;
reason?: string;
target: string;
scope: InteractionScope;
reason?: string;
}
export interface ProductContext {
safe_after_shaving?: boolean;
safe_after_acids?: boolean;
safe_after_retinoids?: boolean;
safe_with_compromised_barrier?: boolean;
low_uv_only?: boolean;
safe_after_shaving?: boolean;
safe_after_acids?: boolean;
safe_after_retinoids?: boolean;
safe_with_compromised_barrier?: boolean;
low_uv_only?: boolean;
}
export interface ProductInventory {
id: string;
product_id: string;
is_opened: boolean;
opened_at?: string;
finished_at?: string;
expiry_date?: string;
current_weight_g?: number;
last_weighed_at?: string;
notes?: string;
created_at: string;
product?: Product;
id: string;
product_id: string;
is_opened: boolean;
opened_at?: string;
finished_at?: string;
expiry_date?: string;
current_weight_g?: number;
last_weighed_at?: string;
notes?: string;
created_at: string;
product?: Product;
}
export interface Product {
id: string;
name: string;
brand: string;
line_name?: string;
sku?: string;
url?: string;
barcode?: string;
category: ProductCategory;
recommended_time: DayTime;
texture?: TextureType;
absorption_speed?: AbsorptionSpeed;
leave_on: boolean;
price_tier?: PriceTier;
size_ml?: number;
full_weight_g?: number;
empty_weight_g?: number;
pao_months?: number;
inci: string[];
actives?: ActiveIngredient[];
recommended_for: SkinType[];
targets: SkinConcern[];
contraindications: string[];
usage_notes?: string;
fragrance_free?: boolean;
essential_oils_free?: boolean;
alcohol_denat_free?: boolean;
pregnancy_safe?: boolean;
product_effect_profile: ProductEffectProfile;
ph_min?: number;
ph_max?: number;
incompatible_with?: ProductInteraction[];
synergizes_with?: string[];
context_rules?: ProductContext;
min_interval_hours?: number;
max_frequency_per_week?: number;
is_medication: boolean;
is_tool: boolean;
needle_length_mm?: number;
personal_tolerance_notes?: string;
personal_repurchase_intent?: boolean;
created_at: string;
updated_at: string;
inventory: ProductInventory[];
id: string;
name: string;
brand: string;
line_name?: string;
sku?: string;
url?: string;
barcode?: string;
category: ProductCategory;
recommended_time: DayTime;
texture?: TextureType;
absorption_speed?: AbsorptionSpeed;
leave_on: boolean;
price_tier?: PriceTier;
size_ml?: number;
full_weight_g?: number;
empty_weight_g?: number;
pao_months?: number;
inci: string[];
actives?: ActiveIngredient[];
recommended_for: SkinType[];
targets: SkinConcern[];
contraindications: string[];
usage_notes?: string;
fragrance_free?: boolean;
essential_oils_free?: boolean;
alcohol_denat_free?: boolean;
pregnancy_safe?: boolean;
product_effect_profile: ProductEffectProfile;
ph_min?: number;
ph_max?: number;
incompatible_with?: ProductInteraction[];
synergizes_with?: string[];
context_rules?: ProductContext;
min_interval_hours?: number;
max_frequency_per_week?: number;
is_medication: boolean;
is_tool: boolean;
needle_length_mm?: number;
personal_tolerance_notes?: string;
personal_repurchase_intent?: boolean;
created_at: string;
updated_at: string;
inventory: ProductInventory[];
}
// ─── Routine types ───────────────────────────────────────────────────────────
export interface RoutineStep {
id: string;
routine_id: string;
product_id?: string;
order_index: number;
action_type?: GroomingAction;
action_notes?: string;
dose?: string;
region?: string;
product?: Product;
id: string;
routine_id: string;
product_id?: string;
order_index: number;
action_type?: GroomingAction;
action_notes?: string;
dose?: string;
region?: string;
product?: Product;
}
export interface Routine {
id: string;
routine_date: string;
part_of_day: PartOfDay;
notes?: string;
created_at: string;
updated_at: string;
steps?: RoutineStep[];
id: string;
routine_date: string;
part_of_day: PartOfDay;
notes?: string;
created_at: string;
updated_at: string;
steps?: RoutineStep[];
}
export interface GroomingSchedule {
id: string;
day_of_week: number;
action: GroomingAction;
notes?: string;
id: string;
day_of_week: number;
action: GroomingAction;
notes?: string;
}
export interface SuggestedStep {
product_id?: string;
action_type?: GroomingAction;
action_notes?: string;
dose?: string;
region?: string;
product_id?: string;
action_type?: GroomingAction;
action_notes?: string;
dose?: string;
region?: string;
why_this_step?: string;
optional?: boolean;
}
export interface RoutineSuggestionSummary {
primary_goal: string;
constraints_applied: string[];
confidence: number;
}
export interface RoutineSuggestion {
steps: SuggestedStep[];
reasoning: string;
steps: SuggestedStep[];
reasoning: string;
summary?: RoutineSuggestionSummary;
}
export interface DayPlan {
date: string;
am_steps: SuggestedStep[];
pm_steps: SuggestedStep[];
reasoning: string;
date: string;
am_steps: SuggestedStep[];
pm_steps: SuggestedStep[];
reasoning: string;
}
export interface BatchSuggestion {
days: DayPlan[];
overall_reasoning: string;
days: DayPlan[];
overall_reasoning: string;
}
// ─── Shopping suggestion types ───────────────────────────────────────────────
export interface ProductSuggestion {
category: string;
product_type: string;
key_ingredients: string[];
target_concerns: string[];
why_needed: string;
recommended_time: string;
frequency: string;
}
export interface ShoppingSuggestionResponse {
suggestions: ProductSuggestion[];
reasoning: string;
}
// ─── Health types ────────────────────────────────────────────────────────────
export interface MedicationUsage {
record_id: string;
medication_record_id: string;
dose_value?: number;
dose_unit?: string;
frequency?: string;
schedule_text?: string;
as_needed: boolean;
valid_from: string;
valid_to?: string;
source_file?: string;
notes?: string;
created_at: string;
updated_at: string;
record_id: string;
medication_record_id: string;
dose_value?: number;
dose_unit?: string;
frequency?: string;
schedule_text?: string;
as_needed: boolean;
valid_from: string;
valid_to?: string;
source_file?: string;
notes?: string;
created_at: string;
updated_at: string;
}
export interface MedicationEntry {
record_id: string;
kind: MedicationKind;
product_name: string;
active_substance?: string;
formulation?: string;
route?: string;
source_file?: string;
notes?: string;
created_at: string;
updated_at: string;
usage_history: MedicationUsage[];
record_id: string;
kind: MedicationKind;
product_name: string;
active_substance?: string;
formulation?: string;
route?: string;
source_file?: string;
notes?: string;
created_at: string;
updated_at: string;
usage_history: MedicationUsage[];
}
export interface LabResult {
record_id: string;
collected_at: string;
test_code: string;
test_name_original?: string;
test_name_loinc?: string;
value_num?: number;
value_text?: string;
value_bool?: boolean;
unit_original?: string;
unit_ucum?: string;
ref_low?: number;
ref_high?: number;
ref_text?: string;
flag?: ResultFlag;
lab?: string;
source_file?: string;
notes?: string;
created_at: string;
updated_at: string;
record_id: string;
collected_at: string;
test_code: string;
test_name_original?: string;
test_name_loinc?: string;
value_num?: number;
value_text?: string;
value_bool?: boolean;
unit_original?: string;
unit_ucum?: string;
ref_low?: number;
ref_high?: number;
ref_text?: string;
flag?: ResultFlag;
lab?: string;
source_file?: string;
notes?: string;
created_at: string;
updated_at: string;
}
// ─── Skin types ──────────────────────────────────────────────────────────────
export interface SkinConditionSnapshot {
id: string;
snapshot_date: string;
overall_state?: OverallSkinState;
skin_type?: SkinType;
texture?: SkinTexture;
hydration_level?: number;
sebum_tzone?: number;
sebum_cheeks?: number;
sensitivity_level?: number;
barrier_state?: BarrierState;
active_concerns: SkinConcern[];
risks: string[];
priorities: string[];
notes?: string;
created_at: string;
id: string;
snapshot_date: string;
overall_state?: OverallSkinState;
skin_type?: SkinType;
texture?: SkinTexture;
hydration_level?: number;
sebum_tzone?: number;
sebum_cheeks?: number;
sensitivity_level?: number;
barrier_state?: BarrierState;
active_concerns: SkinConcern[];
risks: string[];
priorities: string[];
notes?: string;
created_at: string;
}

View file

@ -1,19 +1,22 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { m } from '$lib/paraglide/messages.js';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
let { children } = $props();
let mobileMenuOpen = $state(false);
const navItems = $derived([
{ href: '/', label: m.nav_dashboard(), icon: '🏠' },
{ href: '/products', label: m.nav_products(), icon: '🧴' },
{ href: '/routines', label: m.nav_routines(), icon: '📋' },
{ href: '/routines/grooming-schedule', label: m.nav_grooming(), icon: '🪒' },
{ href: '/health/medications', label: m.nav_medications(), icon: '💊' },
{ href: '/health/lab-results', label: m["nav_labResults"](), icon: '🔬' },
{ href: '/skin', label: m.nav_skin(), icon: '✨' }
{ href: resolve('/'), label: m.nav_dashboard(), icon: '🏠' },
{ href: resolve('/products'), label: m.nav_products(), icon: '🧴' },
{ href: resolve('/routines'), label: m.nav_routines(), icon: '📋' },
{ href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: '🪒' },
{ href: resolve('/health/medications'), label: m.nav_medications(), icon: '💊' },
{ href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: '🔬' },
{ href: resolve('/skin'), label: m.nav_skin(), icon: '✨' }
]);
function isActive(href: string) {
@ -28,9 +31,68 @@
}
</script>
<div class="flex min-h-screen bg-background">
<!-- Sidebar -->
<nav class="w-56 shrink-0 border-r border-border bg-card px-3 py-6">
<div class="flex min-h-screen flex-col bg-background md:flex-row">
<!-- Mobile header -->
<header class="flex items-center justify-between border-b border-border bg-card px-4 py-3 md:hidden">
<div>
<span class="text-sm font-semibold tracking-tight">{m["nav_appName"]()}</span>
</div>
<button
type="button"
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label="Toggle menu"
>
{#if mobileMenuOpen}
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
{/if}
</button>
</header>
<!-- Mobile drawer overlay -->
{#if mobileMenuOpen}
<!-- Backdrop: closes drawer on click -->
<button
type="button"
class="fixed inset-0 z-50 bg-black/50 md:hidden"
onclick={() => (mobileMenuOpen = false)}
aria-label={m.common_cancel()}
></button>
<!-- Drawer (same z-50 but later in DOM → on top of backdrop) -->
<nav
class="fixed inset-y-0 left-0 z-50 w-64 overflow-y-auto bg-card px-3 py-6 md:hidden"
>
<div class="mb-8 px-3">
<h1 class="text-lg font-semibold tracking-tight">{m["nav_appName"]()}</h1>
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p>
</div>
<ul class="space-y-1">
{#each navItems as item}
<li>
<a
href={item.href}
onclick={() => (mobileMenuOpen = false)}
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors
{isActive(item.href)
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
>
<span class="text-base">{item.icon}</span>
{item.label}
</a>
</li>
{/each}
</ul>
<div class="mt-6 px-3">
<LanguageSwitcher />
</div>
</nav>
{/if}
<!-- Desktop Sidebar -->
<nav class="hidden w-56 shrink-0 flex-col border-r border-border bg-card px-3 py-6 md:flex">
<div class="mb-8 px-3">
<h1 class="text-lg font-semibold tracking-tight">{m["nav_appName"]()}</h1>
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p>
@ -57,7 +119,7 @@
</nav>
<!-- Main content -->
<main class="flex-1 overflow-auto p-8">
<main class="flex-1 overflow-auto p-4 md:p-8">
{@render children()}
</main>
</div>

View file

@ -1,4 +1,5 @@
import { getRoutines, getSkinSnapshots } from '$lib/api';
import type { SkinConditionSnapshot } from '$lib/types';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
@ -8,7 +9,7 @@ export const load: PageServerLoad = async () => {
]);
return {
recentRoutines: routines.slice(0, 10),
latestSnapshot: snapshots.at(-1) ?? null
latestSnapshot: getFreshestSnapshot(snapshots)
};
};
@ -17,3 +18,12 @@ function recentDate(daysAgo: number): string {
d.setDate(d.getDate() - daysAgo);
return d.toISOString().slice(0, 10);
}
function getFreshestSnapshot(snapshots: SkinConditionSnapshot[]): SkinConditionSnapshot | null {
if (!snapshots.length) return null;
return snapshots.reduce((freshest, current) => {
if (current.snapshot_date > freshest.snapshot_date) return current;
if (current.snapshot_date < freshest.snapshot_date) return freshest;
return current.created_at > freshest.created_at ? current : freshest;
});
}

View file

@ -1,8 +1,9 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
@ -16,7 +17,6 @@
TableHeader,
TableRow
} from '$lib/components/ui/table';
import { goto } from '$app/navigation';
let { data, form }: { data: PageData; form: ActionData } = $props();
@ -33,6 +33,12 @@
let showForm = $state(false);
let selectedFlag = $state('');
let filterFlag = $derived(data.flag ?? '');
function onFlagChange(v: string) {
const base = resolve('/health/lab-results');
const url = v ? base + '?flag=' + v : base;
goto(url, { replaceState: true });
}
</script>
<svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head>
@ -61,9 +67,7 @@
<Select
type="single"
value={filterFlag}
onValueChange={(v) => {
goto(v ? `/health/lab-results?flag=${v}` : '/health/lab-results');
}}
onValueChange={onFlagChange}
>
<SelectTrigger class="w-32">{filterFlag || m["labResults_flagAll"]()}</SelectTrigger>
<SelectContent>
@ -79,7 +83,7 @@
<Card>
<CardHeader><CardTitle>{m["labResults_newTitle"]()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
<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 />
@ -125,7 +129,8 @@
</Card>
{/if}
<div class="rounded-md border border-border">
<!-- Desktop: table -->
<div class="hidden rounded-md border border-border md:block">
<Table>
<TableHeader>
<TableRow>
@ -173,4 +178,34 @@
</TableBody>
</Table>
</div>
<!-- Mobile: cards -->
<div class="flex flex-col gap-3 md:hidden">
{#each data.results as r (r.record_id)}
<div class="rounded-lg border border-border p-4 flex flex-col gap-1">
<div class="flex items-start justify-between gap-2">
<span class="font-medium">{r.test_name_original ?? r.test_code}</span>
{#if r.flag}
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {flagColors[r.flag] ?? ''}">
{r.flag}
</span>
{/if}
</div>
<p class="text-sm text-muted-foreground">{r.collected_at.slice(0, 10)}</p>
<div class="flex items-center gap-2 text-sm">
<span class="font-mono text-xs text-muted-foreground">{r.test_code}</span>
{#if r.value_num != null}
<span>{r.value_num} {r.unit_original ?? ''}</span>
{:else if r.value_text}
<span>{r.value_text}</span>
{/if}
</div>
{#if r.lab}
<p class="text-xs text-muted-foreground">{r.lab}</p>
{/if}
</div>
{:else}
<p class="py-8 text-center text-sm text-muted-foreground">{m["labResults_noResults"]()}</p>
{/each}
</div>
</div>

View file

@ -56,7 +56,7 @@
<Card>
<CardHeader><CardTitle>{m["medications_newTitle"]()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1 col-span-2">
<Label>{m.medications_kind()}</Label>
<input type="hidden" name="kind" value={kind} />

View file

@ -1,6 +1,8 @@
<script lang="ts">
import type { PageData } from './$types';
import type { Product } from '$lib/types';
import { resolve } from '$app/paths';
import { SvelteMap } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
@ -37,7 +39,7 @@
return bc !== 0 ? bc : a.name.localeCompare(b.name);
});
const map = new Map<string, Product[]>();
const map = new SvelteMap<string, Product[]>();
for (const p of items) {
if (!map.has(p.category)) map.set(p.category, []);
map.get(p.category)!.push(p);
@ -63,10 +65,13 @@
<h2 class="text-2xl font-bold tracking-tight">{m.products_title()}</h2>
<p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p>
</div>
<Button href="/products/new">{m["products_addNew"]()}</Button>
<div class="flex gap-2">
<Button href={resolve('/products/suggest')} variant="outline">{m["products_suggest"]()}</Button>
<Button href={resolve('/products/new')}>{m["products_addNew"]()}</Button>
</div>
</div>
<div class="flex gap-1">
<div class="flex flex-wrap gap-1">
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
<Button
variant={ownershipFilter === f ? 'default' : 'outline'}
@ -78,7 +83,8 @@
{/each}
</div>
<div class="rounded-md border border-border">
<!-- Desktop: table -->
<div class="hidden rounded-md border border-border md:block">
<Table>
<TableHeader>
<TableRow>
@ -128,4 +134,41 @@
</TableBody>
</Table>
</div>
<!-- Mobile: cards -->
<div class="flex flex-col gap-3 md:hidden">
{#if totalCount === 0}
<p class="py-8 text-center text-sm text-muted-foreground">{m["products_noProducts"]()}</p>
{:else}
{#each groupedProducts as [category, products] (category)}
<div class="border-b border-border pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{category.replace(/_/g, ' ')}
</div>
{#each products as product (product.id)}
<a
href="/products/{product.id}"
class="block rounded-lg border border-border p-4 hover:bg-muted/50"
>
<div class="flex items-start justify-between gap-2">
<div>
<p class="font-medium">{product.name}</p>
<p class="text-sm text-muted-foreground">{product.brand}</p>
</div>
<span class="shrink-0 text-xs uppercase text-muted-foreground">{product.recommended_time}</span>
</div>
{#if product.targets.length}
<div class="mt-2 flex flex-wrap gap-1">
{#each product.targets.slice(0, 3) as t (t)}
<Badge variant="secondary" class="text-xs">{t.replace(/_/g, ' ')}</Badge>
{/each}
{#if product.targets.length > 3}
<span class="text-xs text-muted-foreground">+{product.targets.length - 3}</span>
{/if}
</div>
{/if}
</a>
{/each}
{/each}
{/if}
</div>
</div>

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
@ -20,10 +21,9 @@
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<a href="/products" class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{product.name}</h2>
<Badge variant="outline">{product.category.replace(/_/g, ' ')}</Badge>
<div>
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
<h2 class="mt-1 text-2xl font-bold tracking-tight">{product.name}</h2>
</div>
{#if form?.error}

View file

@ -184,9 +184,8 @@ export const actions: Actions = {
const contextRules = parseContextRules(form);
if (contextRules) payload.context_rules = contextRules;
let product;
try {
product = await createProduct(payload);
await createProduct(payload);
} catch (e) {
return fail(500, { error: (e as Error).message });
}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ActionData } from './$types';
import { m } from '$lib/paraglide/messages.js';
import { Button } from '$lib/components/ui/button';
@ -20,7 +21,7 @@
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<a href="/products" class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m["products_newTitle"]()}</h2>
</div>

View file

@ -0,0 +1,25 @@
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
suggest: async ({ fetch }) => {
try {
const res = await fetch('/api/products/suggest', {
method: 'POST',
});
if (!res.ok) {
const err = await res.text();
return fail(res.status, { error: err || 'Failed to get suggestions' });
}
const data = await res.json();
return {
suggestions: data.suggestions,
reasoning: data.reasoning,
};
} catch (e) {
return fail(500, { error: String(e) });
}
},
} satisfies Actions;

View file

@ -0,0 +1,123 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ProductSuggestion } from '$lib/types';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
let suggestions = $state<ProductSuggestion[] | null>(null);
let reasoning = $state('');
let loading = $state(false);
let errorMsg = $state<string | null>(null);
function enhanceForm() {
loading = true;
errorMsg = null;
return async ({ result, update }: { result: { type: string; data?: Record<string, unknown> }; update: (opts?: { reset?: boolean }) => Promise<void> }) => {
loading = false;
if (result.type === 'success' && result.data?.suggestions) {
suggestions = result.data.suggestions as ProductSuggestion[];
reasoning = result.data.reasoning as string;
errorMsg = null;
} else if (result.type === 'failure') {
errorMsg = (result.data?.error as string) ?? m["suggest_errorDefault"]();
}
await update({ reset: false });
};
}
</script>
<svelte:head><title>{m["products_suggestTitle"]()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m["products_suggestTitle"]()}</h2>
</div>
{#if errorMsg}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{errorMsg}</div>
{/if}
<Card>
<CardHeader><CardTitle class="text-base">{m["products_suggestSubtitle"]()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/suggest" use:enhance={enhanceForm} class="space-y-4">
<p class="text-sm text-muted-foreground">
{m["products_suggestDescription"]()}
</p>
<Button type="submit" disabled={loading} class="w-full">
{#if loading}
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
{m["products_suggestGenerating"]()}
{:else}
{m["products_suggestBtn"]()}
{/if}
</Button>
</form>
</CardContent>
</Card>
{#if suggestions && suggestions.length > 0}
{#if reasoning}
<Card class="border-muted bg-muted/30">
<CardContent class="pt-4">
<p class="text-sm text-muted-foreground italic">{reasoning}</p>
</CardContent>
</Card>
{/if}
<div class="space-y-4">
<h3 class="text-lg font-semibold">{m["products_suggestResults"]()}</h3>
{#each suggestions as s (s.product_type)}
<Card>
<CardContent class="pt-4">
<div class="space-y-3">
<div class="flex items-start justify-between gap-2">
<h4 class="font-medium">{s.product_type}</h4>
<Badge variant="secondary" class="shrink-0">{s.category}</Badge>
</div>
{#if s.key_ingredients.length > 0}
<div class="flex flex-wrap gap-1">
{#each s.key_ingredients as ing (ing)}
<Badge variant="outline" class="text-xs">{ing}</Badge>
{/each}
</div>
{/if}
{#if s.target_concerns.length > 0}
<div class="flex flex-wrap gap-1">
{#each s.target_concerns as concern (concern)}
<Badge class="text-xs">{concern.replace(/_/g, ' ')}</Badge>
{/each}
</div>
{/if}
<p class="text-sm text-muted-foreground">{s.why_needed}</p>
<div class="flex gap-4 text-xs text-muted-foreground">
<span>{m["products_suggestTime"]()}: {s.recommended_time.toUpperCase()}</span>
<span>{m["products_suggestFrequency"]()}: {s.frequency}</span>
</div>
</div>
</CardContent>
</Card>
{/each}
</div>
<form method="POST" action="?/suggest" use:enhance={enhanceForm}>
<Button variant="outline" type="submit" disabled={loading}>
{m["products_suggestRegenerate"]()}
</Button>
</form>
{:else if suggestions && suggestions.length === 0}
<Card>
<CardContent class="py-8 text-center text-muted-foreground">
{m["products_suggestNoResults"]()}
</CardContent>
</Card>
{/if}
</div>

View file

@ -1,5 +1,6 @@
<script lang="ts">
import type { PageData } from './$types';
import { resolve } from '$app/paths';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
@ -22,14 +23,14 @@
<svelte:head><title>{m.routines_title()} — innercontext</title></svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">{m.routines_title()}</h2>
<p class="text-muted-foreground">{m.routines_count({ count: data.routines.length })}</p>
</div>
<div class="flex gap-2">
<Button href="/routines/suggest" variant="outline">{m["routines_suggestAI"]()}</Button>
<Button href="/routines/new">{m["routines_addNew"]()}</Button>
<div class="flex flex-wrap gap-2">
<Button href={resolve('/routines/suggest')} variant="outline">{m["routines_suggestAI"]()}</Button>
<Button href={resolve('/routines/new')}>{m["routines_addNew"]()}</Button>
</div>
</div>

View file

@ -1,5 +1,8 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
import { updateRoutineStep } from '$lib/api';
import type { GroomingAction, RoutineStep } from '$lib/types';
import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
@ -7,18 +10,132 @@
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import {
Select,
SelectContent,
SelectGroup,
SelectGroupHeading,
SelectItem,
SelectTrigger
} from '$lib/components/ui/select';
import { Separator } from '$lib/components/ui/separator';
import { SvelteMap } from 'svelte/reactivity';
let { data, form }: { data: PageData; form: ActionData } = $props();
let { routine, products } = $derived(data);
// ── Steps local state (synced from server data) ───────────────
let steps = $derived([...(routine.steps ?? [])].sort((a, b) => a.order_index - b.order_index));
const nextOrderIndex = $derived(
steps.length ? Math.max(...steps.map((s) => s.order_index)) + 1 : 0
);
// ── Drag & drop reordering ────────────────────────────────────
let dndSaving = $state(false);
function handleConsider(e: CustomEvent<DndEvent<RoutineStep>>) {
steps = e.detail.items;
}
async function handleFinalize(e: CustomEvent<DndEvent<RoutineStep>>) {
const newItems = e.detail.items;
// Assign new order_index = position in array, detect which changed
const updated = newItems.map((s, i) => ({ ...s, order_index: i }));
const changed = updated.filter((s, i) => s.order_index !== newItems[i].order_index);
steps = updated;
if (changed.length) {
dndSaving = true;
try {
await Promise.all(
changed.map((s) => updateRoutineStep(s.id, { order_index: s.order_index }))
);
} finally {
dndSaving = false;
}
}
}
// ── Inline editing ────────────────────────────────────────────
let editingStepId = $state<string | null>(null);
let editDraft = $state<Partial<RoutineStep>>({});
let editSaving = $state(false);
let editError = $state('');
function startEdit(step: RoutineStep) {
editingStepId = step.id;
editDraft = {
dose: step.dose ?? '',
region: step.region ?? '',
product_id: step.product_id,
action_type: step.action_type,
action_notes: step.action_notes ?? ''
};
editError = '';
}
async function saveEdit(step: RoutineStep) {
editSaving = true;
editError = '';
try {
const payload: Record<string, unknown> = {};
if (step.product_id !== undefined) {
payload.product_id = editDraft.product_id;
payload.dose = editDraft.dose || null;
payload.region = editDraft.region || null;
} else {
payload.action_type = editDraft.action_type;
payload.action_notes = editDraft.action_notes || null;
}
const updatedStep = await updateRoutineStep(step.id, payload);
steps = steps.map((s) => (s.id === step.id ? { ...s, ...updatedStep } : s));
editingStepId = null;
} catch (err) {
editError = (err as Error).message;
} finally {
editSaving = false;
}
}
function cancelEdit() {
editingStepId = null;
editDraft = {};
editError = '';
}
// ── Add step form ─────────────────────────────────────────────
let showStepForm = $state(false);
let selectedProductId = $state('');
const nextOrderIndex = $derived(
routine.steps.length ? Math.max(...routine.steps.map((s) => s.order_index)) + 1 : 0
);
const GROOMING_ACTIONS: GroomingAction[] = ['shaving_razor', 'shaving_oneblade', 'dermarolling'];
const CATEGORY_ORDER = [
'cleanser', 'toner', 'essence', 'serum', 'moisturizer',
'spf', 'mask', 'exfoliant', 'spot_treatment', 'oil',
'hair_treatment', 'tool', 'other'
];
function formatCategory(cat: string): string {
return cat.charAt(0).toUpperCase() + cat.slice(1).replace(/_/g, ' ');
}
const groupedProducts = $derived.by(() => {
const groups = new SvelteMap<string, typeof products>();
for (const p of products) {
const key = p.category ?? 'other';
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(p);
}
for (const list of groups.values()) {
list.sort((a, b) => {
const b_cmp = (a.brand ?? '').localeCompare(b.brand ?? '');
return b_cmp !== 0 ? b_cmp : a.name.localeCompare(b.name);
});
}
return CATEGORY_ORDER
.filter((c) => groups.has(c))
.map((c) => [c, groups.get(c)!] as const);
});
</script>
<svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head>
@ -43,7 +160,7 @@
<!-- Steps -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">{m.routines_steps({ count: routine.steps.length })}</h3>
<h3 class="text-lg font-semibold">{m.routines_steps({ count: steps.length })}</h3>
<Button variant="outline" size="sm" onclick={() => (showStepForm = !showStepForm)}>
{showStepForm ? m.common_cancel() : m["routines_addStep"]()}
</Button>
@ -66,8 +183,13 @@
{/if}
</SelectTrigger>
<SelectContent>
{#each products as p (p.id)}
<SelectItem value={p.id}>{p.name} ({p.brand})</SelectItem>
{#each groupedProducts as [cat, items]}
<SelectGroup>
<SelectGroupHeading>{formatCategory(cat)}</SelectGroupHeading>
{#each items as p (p.id)}
<SelectItem value={p.id}>{p.name} · {p.brand}</SelectItem>
{/each}
</SelectGroup>
{/each}
</SelectContent>
</Select>
@ -89,33 +211,148 @@
</Card>
{/if}
{#if routine.steps.length}
<div class="space-y-2">
{#each routine.steps.toSorted((a, b) => a.order_index - b.order_index) as step (step.id)}
<div class="flex items-center justify-between rounded-md border border-border px-4 py-3">
<div class="flex items-center gap-3">
<span class="text-xs text-muted-foreground w-4">{step.order_index}</span>
<div>
{#if step.product_id}
{@const product = products.find((p) => p.id === step.product_id)}
<p class="text-sm font-medium">{product?.name ?? step.product_id}</p>
{#if product?.brand}<p class="text-xs text-muted-foreground">{product.brand}</p>{/if}
{:else if step.action_type}
<p class="text-sm font-medium">{step.action_type.replace(/_/g, ' ')}</p>
{#if steps.length}
<div
use:dragHandleZone={{ items: steps, flipDurationMs: 200, dragDisabled: !!editingStepId || dndSaving }}
onconsider={handleConsider}
onfinalize={handleFinalize}
class="space-y-2"
>
{#each steps as step, i (step.id)}
<div class="rounded-md border border-border bg-background">
{#if editingStepId === step.id}
<!-- ── Edit mode ── -->
<div class="px-4 py-3 space-y-3">
{#if step.product_id !== undefined}
<!-- Product step: change product / dose / region -->
<div class="space-y-1">
<Label>{m.routines_product()}</Label>
<Select
type="single"
value={editDraft.product_id ?? ''}
onValueChange={(v) => (editDraft.product_id = v || undefined)}
>
<SelectTrigger>
{#if editDraft.product_id}
{products.find((p) => p.id === editDraft.product_id)?.name ?? m["routines_selectProduct"]()}
{:else}
{m["routines_selectProduct"]()}
{/if}
</SelectTrigger>
<SelectContent>
{#each groupedProducts as [cat, items]}
<SelectGroup>
<SelectGroupHeading>{formatCategory(cat)}</SelectGroupHeading>
{#each items as p (p.id)}
<SelectItem value={p.id}>{p.name} · {p.brand}</SelectItem>
{/each}
</SelectGroup>
{/each}
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<Label>{m.routines_dose()}</Label>
<Input
value={editDraft.dose ?? ''}
oninput={(e) => (editDraft.dose = e.currentTarget.value)}
placeholder={m["routines_dosePlaceholder"]()}
/>
</div>
<div class="space-y-1">
<Label>{m.routines_region()}</Label>
<Input
value={editDraft.region ?? ''}
oninput={(e) => (editDraft.region = e.currentTarget.value)}
placeholder={m["routines_regionPlaceholder"]()}
/>
</div>
</div>
{:else}
<p class="text-sm text-muted-foreground">{m["routines_unknownStep"]()}</p>
<!-- Action step: change action_type / notes -->
<div class="space-y-1">
<Label>Action</Label>
<Select
type="single"
value={editDraft.action_type ?? ''}
onValueChange={(v) =>
(editDraft.action_type = (v || undefined) as GroomingAction | undefined)}
>
<SelectTrigger>
{editDraft.action_type?.replace(/_/g, ' ') ?? 'Select action'}
</SelectTrigger>
<SelectContent>
{#each GROOMING_ACTIONS as action (action)}
<SelectItem value={action}>{action.replace(/_/g, ' ')}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label>Notes</Label>
<Input
value={editDraft.action_notes ?? ''}
oninput={(e) => (editDraft.action_notes = e.currentTarget.value)}
placeholder="optional notes"
/>
</div>
{/if}
{#if editError}
<p class="text-sm text-destructive">{editError}</p>
{/if}
<div class="flex gap-2">
<Button size="sm" onclick={() => saveEdit(step)} disabled={editSaving}>
{m.common_save()}
</Button>
<Button size="sm" variant="outline" onclick={cancelEdit} disabled={editSaving}>
{m.common_cancel()}
</Button>
</div>
</div>
{#if step.dose}
<span class="text-xs text-muted-foreground">{step.dose}</span>
{/if}
</div>
<form method="POST" action="?/removeStep" use:enhance>
<input type="hidden" name="step_id" value={step.id} />
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">
×
</Button>
</form>
{:else}
<!-- ── View mode ── -->
<div class="flex items-center gap-1 px-2 py-3">
<span
use:dragHandle
class="cursor-grab select-none px-1 text-muted-foreground/60 hover:text-muted-foreground"
aria-label="drag to reorder"
>⋮⋮</span>
<span class="w-5 shrink-0 text-xs text-muted-foreground">{i + 1}.</span>
<div class="flex-1 min-w-0 px-1">
{#if step.product_id}
{@const product = products.find((p) => p.id === step.product_id)}
<p class="text-sm font-medium truncate">{product?.name ?? step.product_id}</p>
{#if product?.brand}<p class="text-xs text-muted-foreground truncate">{product.brand}</p>{/if}
{:else if step.action_type}
<p class="text-sm font-medium">{step.action_type.replace(/_/g, ' ')}</p>
{:else}
<p class="text-sm text-muted-foreground">{m["routines_unknownStep"]()}</p>
{/if}
</div>
{#if step.dose}
<span class="shrink-0 text-xs text-muted-foreground">{step.dose}</span>
{/if}
<Button
variant="ghost"
size="sm"
class="shrink-0 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onclick={() => startEdit(step)}
aria-label="edit step"
>✎</Button>
<form method="POST" action="?/removeStep" use:enhance>
<input type="hidden" name="step_id" value={step.id} />
<Button
type="submit"
variant="ghost"
size="sm"
class="shrink-0 h-7 w-7 p-0 text-destructive hover:text-destructive"
>×</Button>
</form>
</div>
{/if}
</div>
{/each}
</div>
@ -126,8 +363,14 @@
<Separator />
<form method="POST" action="?/delete" use:enhance
onsubmit={(e) => { if (!confirm(m["routines_confirmDelete"]())) e.preventDefault(); }}>
<form
method="POST"
action="?/delete"
use:enhance
onsubmit={(e) => {
if (!confirm(m["routines_confirmDelete"]())) e.preventDefault();
}}
>
<Button type="submit" variant="destructive" size="sm">{m["routines_deleteRoutine"]()}</Button>
</form>
</div>

View file

@ -14,13 +14,22 @@ export const actions: Actions = {
const routine_date = form.get('routine_date') as string;
const part_of_day = form.get('part_of_day') as 'am' | 'pm';
const notes = (form.get('notes') as string) || undefined;
const include_minoxidil_beard = form.get('include_minoxidil_beard') === 'on';
const leaving_home =
part_of_day === 'am' ? form.get('leaving_home') === 'on' : undefined;
if (!routine_date || !part_of_day) {
return fail(400, { error: 'Data i pora dnia są wymagane.' });
}
try {
const suggestion = await suggestRoutine({ routine_date, part_of_day, notes });
const suggestion = await suggestRoutine({
routine_date,
part_of_day,
notes,
include_minoxidil_beard,
leaving_home
});
return { suggestion, routine_date, part_of_day };
} catch (e) {
return fail(502, { error: (e as Error).message });
@ -32,6 +41,8 @@ export const actions: Actions = {
const from_date = form.get('from_date') as string;
const to_date = form.get('to_date') as string;
const notes = (form.get('notes') as string) || undefined;
const include_minoxidil_beard = form.get('include_minoxidil_beard') === 'on';
const minimize_products = form.get('minimize_products') === 'on';
if (!from_date || !to_date) {
return fail(400, { error: 'Daty początkowa i końcowa są wymagane.' });
@ -44,7 +55,7 @@ export const actions: Actions = {
}
try {
const batch = await suggestBatch({ from_date, to_date, notes });
const batch = await suggestBatch({ from_date, to_date, notes, include_minoxidil_beard, minimize_products });
return { batch, from_date, to_date };
} catch (e) {
return fail(502, { error: (e as Error).message });

View file

@ -1,7 +1,8 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import { SvelteSet } from 'svelte/reactivity';
import type { ActionData, PageData } from './$types';
import type { PageData } from './$types';
import type { BatchSuggestion, RoutineSuggestion, SuggestedStep } from '$lib/types';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
@ -12,7 +13,7 @@
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
let { data, form }: { data: PageData; form: ActionData } = $props();
let { data }: { data: PageData } = $props();
const productMap = $derived(Object.fromEntries(data.products.map((p) => [p.id, p])));
@ -44,7 +45,7 @@
function stepMeta(step: SuggestedStep): string {
const parts: string[] = [];
if (step.dose) parts.push(step.dose);
if (step.region) parts.push(step.region);
if (step.region && step.region.toLowerCase() !== 'face') parts.push(step.region);
if (step.action_notes && !step.action_type) parts.push(step.action_notes);
return parts.join(' · ');
}
@ -104,7 +105,7 @@
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<a href="/routines" class="text-sm text-muted-foreground hover:underline">{m["suggest_backToRoutines"]()}</a>
<a href={resolve('/routines')} class="text-sm text-muted-foreground hover:underline">{m["suggest_backToRoutines"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m.suggest_title()}</h2>
</div>
@ -152,6 +153,32 @@
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
></textarea>
</div>
{#if partOfDay === 'am'}
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
<input
id="single_leaving_home"
name="leaving_home"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-input"
/>
<div class="space-y-0.5">
<Label for="single_leaving_home" class="font-medium">{m["suggest_leavingHomeLabel"]()}</Label>
<p class="text-xs text-muted-foreground">{m["suggest_leavingHomeHint"]()}</p>
</div>
</div>
{/if}
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
<input
id="single_include_minoxidil_beard"
name="include_minoxidil_beard"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-input"
/>
<div class="space-y-0.5">
<Label for="single_include_minoxidil_beard" class="font-medium">{m["suggest_minoxidilToggleLabel"]()}</Label>
<p class="text-xs text-muted-foreground">{m["suggest_minoxidilToggleHint"]()}</p>
</div>
</div>
<Button type="submit" disabled={loadingSingle} class="w-full">
{#if loadingSingle}
@ -182,6 +209,20 @@
</CardContent>
</Card>
{#if suggestion.summary}
<Card class="border-muted bg-muted/30">
<CardContent class="space-y-2 pt-4">
<p class="text-sm"><span class="font-medium">{m["suggest_summaryPrimaryGoal"]()}:</span> {suggestion.summary.primary_goal || '—'}</p>
<p class="text-sm"><span class="font-medium">{m["suggest_summaryConfidence"]()}:</span> {Math.round((suggestion.summary.confidence ?? 0) * 100)}%</p>
{#if suggestion.summary.constraints_applied?.length}
<p class="text-xs text-muted-foreground">
{m["suggest_summaryConstraints"]()}: {suggestion.summary.constraints_applied.join(' · ')}
</p>
{/if}
</CardContent>
</Card>
{/if}
<!-- Steps -->
<div class="space-y-2">
{#each suggestion.steps as step, i (i)}
@ -190,10 +231,18 @@
{i + 1}
</span>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">{stepLabel(step)}</p>
<div class="flex items-center gap-2">
<p class="text-sm font-medium">{stepLabel(step)}</p>
{#if step.optional}
<Badge variant="secondary">{m["suggest_stepOptionalBadge"]()}</Badge>
{/if}
</div>
{#if stepMeta(step)}
<p class="text-xs text-muted-foreground">{stepMeta(step)}</p>
{/if}
{#if step.why_this_step}
<p class="text-xs text-muted-foreground italic">{step.why_this_step}</p>
{/if}
</div>
</div>
{/each}
@ -247,6 +296,30 @@
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
></textarea>
</div>
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
<input
id="batch_include_minoxidil_beard"
name="include_minoxidil_beard"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-input"
/>
<div class="space-y-0.5">
<Label for="batch_include_minoxidil_beard" class="font-medium">{m["suggest_minoxidilToggleLabel"]()}</Label>
<p class="text-xs text-muted-foreground">{m["suggest_minoxidilToggleHint"]()}</p>
</div>
</div>
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
<input
id="batch_minimize_products"
name="minimize_products"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-input"
/>
<div class="space-y-0.5">
<Label for="batch_minimize_products" class="font-medium">Minimalizuj produkty</Label>
<p class="text-xs text-muted-foreground">Ogranicz liczbę różnych produktów</p>
</div>
</div>
<Button type="submit" disabled={loadingBatch} class="w-full">
{#if loadingBatch}

View file

@ -19,6 +19,7 @@ export const actions: Actions = {
const sensitivity_level = form.get('sensitivity_level') as string;
const barrier_state = form.get('barrier_state') as string;
const active_concerns_raw = form.get('active_concerns') as string;
const priorities_raw = form.get('priorities') as string;
if (!snapshot_date) {
return fail(400, { error: 'Date is required' });
@ -29,11 +30,16 @@ export const actions: Actions = {
.map((c) => c.trim())
.filter(Boolean) ?? [];
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 };
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;
@ -63,6 +69,7 @@ export const actions: Actions = {
const sensitivity_level = form.get('sensitivity_level') as string;
const barrier_state = form.get('barrier_state') as string;
const active_concerns_raw = form.get('active_concerns') as string;
const priorities_raw = form.get('priorities') as string;
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;
@ -74,7 +81,12 @@ export const actions: Actions = {
.map((c) => c.trim())
.filter(Boolean) ?? [];
const body: Record<string, unknown> = { active_concerns };
const priorities = priorities_raw
?.split(',')
.map((p) => p.trim())
.filter(Boolean) ?? [];
const body: Record<string, unknown> = { active_concerns, priorities };
if (snapshot_date) body.snapshot_date = snapshot_date;
if (overall_state) body.overall_state = overall_state;
if (texture) body.texture = texture;

View file

@ -66,6 +66,7 @@
let sebumTzone = $state('');
let sebumCheeks = $state('');
let activeConcernsRaw = $state('');
let prioritiesRaw = $state('');
let notes = $state('');
// Edit state
@ -80,6 +81,7 @@
let editSebumTzone = $state('');
let editSebumCheeks = $state('');
let editActiveConcernsRaw = $state('');
let editPrioritiesRaw = $state('');
let editNotes = $state('');
function startEdit(snap: (typeof data.snapshots)[number]) {
@ -94,6 +96,7 @@
editSebumTzone = snap.sebum_tzone != null ? String(snap.sebum_tzone) : '';
editSebumCheeks = snap.sebum_cheeks != null ? String(snap.sebum_cheeks) : '';
editActiveConcernsRaw = snap.active_concerns?.join(', ') ?? '';
editPrioritiesRaw = snap.priorities?.join(', ') ?? '';
editNotes = snap.notes ?? '';
showForm = false;
}
@ -132,6 +135,7 @@
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) activeConcernsRaw = r.active_concerns.join(', ');
if (r.priorities?.length) prioritiesRaw = r.priorities.join(', ');
if (r.notes) notes = r.notes;
aiPanelOpen = false;
} catch (e) {
@ -188,7 +192,7 @@
</p>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
accept="image/heic,image/heif,image/jpeg,image/png,image/webp"
multiple
onchange={handleFileSelect}
class="block w-full text-sm text-muted-foreground
@ -220,7 +224,7 @@
<Card>
<CardHeader><CardTitle>{m["skin_newSnapshotTitle"]()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
<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="snapshot_date">{m.skin_date()}</Label>
<Input
@ -299,6 +303,10 @@
<Label for="active_concerns">{m["skin_activeConcerns"]()}</Label>
<Input id="active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={activeConcernsRaw} />
</div>
<div class="space-y-1 col-span-2">
<Label for="priorities">{m["skin_priorities"]()}</Label>
<Input id="priorities" name="priorities" placeholder={m["skin_prioritiesPlaceholder"]()} bind:value={prioritiesRaw} />
</div>
<div class="space-y-1 col-span-2">
<Label for="notes">{m.skin_notes()}</Label>
<Input id="notes" name="notes" bind:value={notes} />
@ -324,7 +332,7 @@
await update();
if (result.type === 'success') editingId = null;
}}
class="grid grid-cols-2 gap-4"
class="grid grid-cols-1 sm:grid-cols-2 gap-4"
>
<input type="hidden" name="id" value={snap.id} />
<div class="space-y-1">
@ -399,6 +407,10 @@
<Label for="edit_active_concerns">{m["skin_activeConcerns"]()}</Label>
<Input id="edit_active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={editActiveConcernsRaw} />
</div>
<div class="space-y-1 col-span-2">
<Label for="edit_priorities">{m["skin_priorities"]()}</Label>
<Input id="edit_priorities" name="priorities" placeholder={m["skin_prioritiesPlaceholder"]()} bind:value={editPrioritiesRaw} />
</div>
<div class="space-y-1 col-span-2">
<Label for="edit_notes">{m.skin_notes()}</Label>
<Input id="edit_notes" name="notes" bind:value={editNotes} />
@ -410,27 +422,29 @@
</form>
{:else}
<!-- Read view -->
<div class="flex items-center justify-between mb-3">
<span class="font-medium">{snap.snapshot_date}</span>
<div class="flex items-center gap-2">
{#if snap.overall_state}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[snap.overall_state] ?? ''}">
{stateLabels[snap.overall_state]?.() ?? snap.overall_state}
</span>
{/if}
{#if snap.texture}
<Badge variant="secondary">{textureLabels[snap.texture]?.() ?? snap.texture}</Badge>
{/if}
<Button variant="ghost" size="sm" onclick={() => startEdit(snap)} class="h-7 px-2 text-xs">
{m.common_edit()}
</Button>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={snap.id} />
<Button type="submit" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive hover:text-destructive">
{m.common_delete()}
</Button>
</form>
<div class="mb-3 space-y-1.5">
<div class="flex items-center justify-between">
<span class="font-medium">{snap.snapshot_date}</span>
<div class="flex items-center gap-1">
<Button variant="ghost" size="sm" onclick={() => startEdit(snap)} class="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground" aria-label={m.common_edit()}>✎</Button>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={snap.id} />
<Button type="submit" variant="ghost" size="sm" class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive" aria-label={m.common_delete()}>×</Button>
</form>
</div>
</div>
{#if snap.overall_state || snap.texture}
<div class="flex flex-wrap items-center gap-1.5">
{#if snap.overall_state}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[snap.overall_state] ?? ''}">
{stateLabels[snap.overall_state]?.() ?? snap.overall_state}
</span>
{/if}
{#if snap.texture}
<Badge variant="secondary">{textureLabels[snap.texture]?.() ?? snap.texture}</Badge>
{/if}
</div>
{/if}
</div>
<div class="grid grid-cols-3 gap-3 text-sm mb-3">
{#if snap.hydration_level != null}
@ -459,6 +473,16 @@
{/each}
</div>
{/if}
{#if snap.priorities?.length}
<div class="mt-2">
<p class="text-xs text-muted-foreground mb-1">{m["skin_prioritiesLabel"]()}</p>
<div class="flex flex-wrap gap-1">
{#each snap.priorities as p (p)}
<Badge variant="outline" class="text-xs">{p}</Badge>
{/each}
</div>
</div>
{/if}
{#if snap.notes}
<p class="mt-2 text-sm text-muted-foreground">{snap.notes}</p>
{/if}

View file

@ -4,6 +4,7 @@ server {
# FastAPI backend — strip /api/ prefix
location /api/ {
client_max_body_size 16m; # up to 3 × 5 MB photos
proxy_pass http://127.0.0.1:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@ -11,18 +12,6 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# MCP endpoint — keep /mcp/ prefix; disable buffering for SSE streaming
location /mcp/ {
proxy_pass http://127.0.0.1:8000/mcp/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
}
# SvelteKit Node server
location / {
proxy_pass http://127.0.0.1:3000;