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 ## Commands
Run all backend commands from the `backend/` directory: Run the backend from the `backend/` directory:
```bash ```bash
# Run scripts # Backend
cd backend && uv run python main.py cd backend && uv run python main.py
# Linting / formatting # Linting / formatting
@ -20,11 +24,27 @@ cd backend && uv run black .
cd backend && uv run isort . 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 ## 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/`) ### Models (`backend/innercontext/models/`)
@ -35,7 +55,7 @@ No test suite exists yet.
| `routine.py` | `routines`, `routine_steps` | | `routine.py` | `routines`, `routine_steps` |
| `skincare.py` | `skin_condition_snapshots` | | `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. **`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). **`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. - 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). - List/complex fields stored as JSON use `sa_column=Column(JSON, nullable=...)` pattern (DB-agnostic; not JSONB).

View file

@ -1,11 +1,11 @@
# innercontext # 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 ## 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) frontend/ SvelteKit web UI (Svelte 5, TypeScript, Tailwind CSS v4)
docs/ Deployment guides docs/ Deployment guides
nginx/ nginx config for production nginx/ nginx config for production
@ -60,14 +60,6 @@ UI available at `http://localhost:5173`.
| `/skincare` | Weekly skin condition snapshots | | `/skincare` | Weekly skin condition snapshots |
| `/health-check` | Liveness probe | | `/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 ## Frontend routes
| Route | Description | | Route | Description |
@ -102,7 +94,6 @@ uv run pytest
## Stack ## Stack
- **Backend:** Python 3.12, FastAPI, Uvicorn, SQLModel 0.0.37 + SQLAlchemy, Pydantic v2, PostgreSQL (psycopg3) - **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 - **Frontend:** SvelteKit 2, Svelte 5 (Runes), TypeScript, Tailwind CSS v4, shadcn-svelte
## Deployment ## Deployment

View file

@ -1,15 +1,16 @@
import os import os
from logging.config import fileConfig from logging.config import fileConfig
from alembic import context
from dotenv import load_dotenv from dotenv import load_dotenv
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool
from sqlmodel import SQLModel from sqlmodel import SQLModel
from alembic import context
load_dotenv() load_dotenv()
# Import all models so their tables are registered in SQLModel.metadata # 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 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 """initial_schema
Revision ID: c2d626a2b36c Revision ID: c2d626a2b36c
Revises: Revises:
Create Date: 2026-02-28 20:13:55.499494 Create Date: 2026-02-28 20:13:55.499494
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
import sqlmodel.sql.sqltypes import sqlmodel.sql.sqltypes
from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = 'c2d626a2b36c' revision: str = "c2d626a2b36c"
down_revision: Union[str, Sequence[str], None] = None down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: 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: def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('grooming_schedule', op.create_table(
sa.Column('id', sa.Uuid(), nullable=False), "grooming_schedule",
sa.Column('day_of_week', sa.Integer(), nullable=False), sa.Column("id", sa.Uuid(), nullable=False),
sa.Column('action', sa.Enum('SHAVING_RAZOR', 'SHAVING_ONEBLADE', 'DERMAROLLING', name='groomingaction'), nullable=False), sa.Column("day_of_week", sa.Integer(), nullable=False),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column(
sa.PrimaryKeyConstraint('id') "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_index(
op.create_table('lab_results', op.f("ix_grooming_schedule_day_of_week"),
sa.Column('record_id', sa.Uuid(), nullable=False), "grooming_schedule",
sa.Column('collected_at', sa.DateTime(), nullable=False), ["day_of_week"],
sa.Column('test_code', sqlmodel.sql.sqltypes.AutoString(), nullable=False), unique=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_lab_results_collected_at'), 'lab_results', ['collected_at'], unique=False) op.create_table(
op.create_index(op.f('ix_lab_results_flag'), 'lab_results', ['flag'], unique=False) "lab_results",
op.create_index(op.f('ix_lab_results_lab'), 'lab_results', ['lab'], unique=False) sa.Column("record_id", sa.Uuid(), nullable=False),
op.create_index(op.f('ix_lab_results_test_code'), 'lab_results', ['test_code'], unique=False) sa.Column("collected_at", sa.DateTime(), nullable=False),
op.create_table('medication_entries', sa.Column("test_code", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('record_id', sa.Uuid(), nullable=False), sa.Column(
sa.Column('kind', sa.Enum('PRESCRIPTION', 'OTC', 'SUPPLEMENT', 'HERBAL', 'OTHER', name='medicationkind'), nullable=False), "test_name_original", sqlmodel.sql.sqltypes.AutoString(), nullable=True
sa.Column('product_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), ),
sa.Column('active_substance', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("test_name_loinc", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('formulation', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("value_num", sa.Float(), nullable=True),
sa.Column('route', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("value_text", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('source_file', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("value_bool", sa.Boolean(), nullable=True),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("unit_original", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column("unit_ucum", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), sa.Column("ref_low", sa.Float(), nullable=True),
sa.PrimaryKeyConstraint('record_id') 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.create_index(op.f('ix_medication_entries_kind'), 'medication_entries', ['kind'], unique=False) op.f("ix_lab_results_collected_at"),
op.create_index(op.f('ix_medication_entries_product_name'), 'medication_entries', ['product_name'], unique=False) "lab_results",
op.create_table('products', ["collected_at"],
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), unique=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_index(op.f("ix_lab_results_flag"), "lab_results", ["flag"], unique=False)
op.create_table('routines', op.create_index(op.f("ix_lab_results_lab"), "lab_results", ["lab"], unique=False)
sa.Column('id', sa.Uuid(), nullable=False), op.create_index(
sa.Column('routine_date', sa.Date(), nullable=False), op.f("ix_lab_results_test_code"), "lab_results", ["test_code"], unique=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_table(
op.create_index(op.f('ix_routines_routine_date'), 'routines', ['routine_date'], unique=False) "medication_entries",
op.create_table('skin_condition_snapshots', sa.Column("record_id", sa.Uuid(), nullable=False),
sa.Column('snapshot_date', sa.Date(), nullable=False), sa.Column(
sa.Column('overall_state', sa.Enum('EXCELLENT', 'GOOD', 'FAIR', 'POOR', name='overallskinstate'), nullable=True), "kind",
sa.Column('skin_type', sa.Enum('DRY', 'OILY', 'COMBINATION', 'SENSITIVE', 'NORMAL', 'ACNE_PRONE', name='skintype'), nullable=True), sa.Enum(
sa.Column('texture', sa.Enum('SMOOTH', 'ROUGH', 'FLAKY', 'BUMPY', name='skintexture'), nullable=True), "PRESCRIPTION",
sa.Column('hydration_level', sa.Integer(), nullable=True), "OTC",
sa.Column('sebum_tzone', sa.Integer(), nullable=True), "SUPPLEMENT",
sa.Column('sebum_cheeks', sa.Integer(), nullable=True), "HERBAL",
sa.Column('sensitivity_level', sa.Integer(), nullable=True), "OTHER",
sa.Column('barrier_state', sa.Enum('INTACT', 'MILDLY_COMPROMISED', 'COMPROMISED', name='barrierstate'), nullable=True), name="medicationkind",
sa.Column('active_concerns', sa.JSON(), nullable=False), ),
sa.Column('risks', sa.JSON(), nullable=False), nullable=False,
sa.Column('priorities', sa.JSON(), nullable=False), ),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("product_name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False), sa.Column(
sa.Column('created_at', sa.DateTime(), nullable=False), "active_substance", sqlmodel.sql.sqltypes.AutoString(), nullable=True
sa.PrimaryKeyConstraint('id'), ),
sa.UniqueConstraint('snapshot_date', name='uq_skin_snapshot_date') 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_index(
op.create_table('medication_usages', op.f("ix_medication_entries_active_substance"),
sa.Column('record_id', sa.Uuid(), nullable=False), "medication_entries",
sa.Column('medication_record_id', sa.Uuid(), nullable=False), ["active_substance"],
sa.Column('dose_value', sa.Float(), nullable=True), unique=False,
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.create_index(op.f('ix_medication_usages_medication_record_id'), 'medication_usages', ['medication_record_id'], unique=False) op.f("ix_medication_entries_kind"), "medication_entries", ["kind"], 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_index(
op.create_table('routine_steps', op.f("ix_medication_entries_product_name"),
sa.Column('id', sa.Uuid(), nullable=False), "medication_entries",
sa.Column('routine_id', sa.Uuid(), nullable=False), ["product_name"],
sa.Column('product_id', sa.Uuid(), nullable=True), unique=False,
sa.Column('order_index', sa.Integer(), nullable=False), )
sa.Column('action_type', sa.Enum('SHAVING_RAZOR', 'SHAVING_ONEBLADE', 'DERMAROLLING', name='groomingaction'), nullable=True), op.create_table(
sa.Column('action_notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True), "products",
sa.Column('dose', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('region', sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("brand", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ), sa.Column(
sa.ForeignKeyConstraint(['routine_id'], ['routines.id'], ), "line_name", sqlmodel.sql.sqltypes.AutoString(length=128), nullable=True
sa.PrimaryKeyConstraint('id') ),
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 ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema.""" """Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### 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_routine_id"), table_name="routine_steps")
op.drop_index(op.f('ix_routine_steps_product_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_table("routine_steps")
op.drop_index(op.f('ix_product_inventory_product_id'), table_name='product_inventory') op.drop_index(
op.drop_table('product_inventory') op.f("ix_product_inventory_product_id"), table_name="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_table("product_inventory")
op.drop_index(op.f('ix_medication_usages_medication_record_id'), table_name='medication_usages') op.drop_index(op.f("ix_medication_usages_valid_to"), table_name="medication_usages")
op.drop_index(op.f('ix_medication_usages_as_needed'), table_name='medication_usages') op.drop_index(
op.drop_table('medication_usages') op.f("ix_medication_usages_valid_from"), table_name="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.drop_index(op.f('ix_routines_routine_date'), table_name='routines') op.f("ix_medication_usages_medication_record_id"),
op.drop_index(op.f('ix_routines_part_of_day'), table_name='routines') table_name="medication_usages",
op.drop_table('routines') )
op.drop_index(op.f('ix_products_price_tier'), table_name='products') op.drop_index(
op.drop_table('products') op.f("ix_medication_usages_as_needed"), table_name="medication_usages"
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_table("medication_usages")
op.drop_index(op.f('ix_medication_entries_active_substance'), table_name='medication_entries') op.drop_index(
op.drop_table('medication_entries') op.f("ix_skin_condition_snapshots_snapshot_date"),
op.drop_index(op.f('ix_lab_results_test_code'), table_name='lab_results') table_name="skin_condition_snapshots",
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_table("skin_condition_snapshots")
op.drop_index(op.f('ix_lab_results_collected_at'), table_name='lab_results') op.drop_index(op.f("ix_routines_routine_date"), table_name="routines")
op.drop_table('lab_results') op.drop_index(op.f("ix_routines_part_of_day"), table_name="routines")
op.drop_index(op.f('ix_grooming_schedule_day_of_week'), table_name='grooming_schedule') op.drop_table("routines")
op.drop_table('grooming_schedule') 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 ### # ### 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 fastapi import APIRouter, Depends, HTTPException, Query
from google.genai import types as genai_types from google.genai import types as genai_types
from pydantic import BaseModel as PydanticBase
from pydantic import ValidationError from pydantic import ValidationError
from sqlmodel import Session, SQLModel, select from sqlmodel import Session, SQLModel, col, select
from db import get_session from db import get_session
from innercontext.api.utils import get_or_404 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 ( from innercontext.models import (
Product, Product,
ProductBase, ProductBase,
@ -19,6 +25,7 @@ from innercontext.models import (
ProductPublic, ProductPublic,
ProductWithInventory, ProductWithInventory,
SkinConcern, SkinConcern,
SkinConditionSnapshot,
) )
from innercontext.models.enums import ( from innercontext.models.enums import (
AbsorptionSpeed, AbsorptionSpeed,
@ -143,6 +150,19 @@ class ProductParseResponse(SQLModel):
needle_length_mm: Optional[float] = None 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): class InventoryCreate(SQLModel):
is_opened: bool = False is_opened: bool = False
opened_at: Optional[date] = None opened_at: Optional[date] = None
@ -163,6 +183,41 @@ class InventoryUpdate(SQLModel):
notes: Optional[str] = None 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 # Product routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -205,7 +260,9 @@ def list_products(
product_ids = [p.id for p in products] product_ids = [p.id for p in products]
inventory_rows = ( inventory_rows = (
session.exec( session.exec(
select(ProductInventory).where(ProductInventory.product_id.in_(product_ids)) select(ProductInventory).where(
col(ProductInventory.product_id).in_(product_ids)
)
).all() ).all()
if product_ids if product_ids
else [] else []
@ -367,17 +424,15 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine):
@router.post("/parse-text", response_model=ProductParseResponse) @router.post("/parse-text", response_model=ProductParseResponse)
def parse_product_text(data: ProductParseRequest) -> ProductParseResponse: def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
client, model = get_gemini_client() response = call_gemini(
response = client.models.generate_content( endpoint="products/parse-text",
model=model,
contents=f"Extract product data from this text:\n\n{data.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(), system_instruction=_product_parse_system_prompt(),
response_mime_type="application/json", response_schema=ProductParseLLMResponse,
response_schema=ProductParseResponse,
max_output_tokens=16384, max_output_tokens=16384,
temperature=0.0,
), ),
user_input=data.text,
) )
raw = response.text raw = response.text
if not raw: if not raw:
@ -387,7 +442,8 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}") raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
try: try:
return ProductParseResponse.model_validate(parsed) llm_parsed = ProductParseLLMResponse.model_validate(parsed)
return ProductParseResponse.model_validate(llm_parsed.model_dump())
except ValidationError as e: except ValidationError as e:
raise HTTPException(status_code=422, detail=e.errors()) raise HTTPException(status_code=422, detail=e.errors())
@ -453,3 +509,425 @@ def create_product_inventory(
session.commit() session.commit()
session.refresh(entry) session.refresh(entry)
return 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 db import get_session
from innercontext.api.utils import get_or_404 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 ( from innercontext.models import (
SkinConditionSnapshot, SkinConditionSnapshot,
SkinConditionSnapshotBase, SkinConditionSnapshotBase,
@ -140,37 +140,44 @@ async def analyze_skin_photos(
if not (1 <= len(photos) <= 3): if not (1 <= len(photos) <= 3):
raise HTTPException(status_code=422, detail="Send between 1 and 3 photos.") raise HTTPException(status_code=422, detail="Send between 1 and 3 photos.")
client, model = get_gemini_client() allowed = {
"image/heic",
allowed = {"image/jpeg", "image/png", "image/webp"} "image/heif",
"image/jpeg",
"image/png",
"image/webp",
}
parts: list[genai_types.Part] = [] parts: list[genai_types.Part] = []
for photo in photos: for photo in photos:
if photo.content_type not in allowed: 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() data = await photo.read()
if len(data) > MAX_IMAGE_BYTES: if len(data) > MAX_IMAGE_BYTES:
raise HTTPException(status_code=413, detail=f"{photo.filename} exceeds 5 MB.") raise HTTPException(
parts.append(genai_types.Part.from_bytes(data=data, mime_type=photo.content_type)) 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( parts.append(
genai_types.Part.from_text( genai_types.Part.from_text(
text="Analyze the skin condition visible in the above photo(s) and return the JSON assessment." text="Analyze the skin condition visible in the above photo(s) and return the JSON assessment."
) )
) )
try: image_summary = f"{len(photos)} image(s): {', '.join((p.content_type or 'unknown') for p in photos)}"
response = client.models.generate_content( response = call_gemini(
model=model, endpoint="skincare/analyze-photos",
contents=parts, contents=parts,
config=genai_types.GenerateContentConfig( config=get_extraction_config(
system_instruction=_skin_photo_system_prompt(), system_instruction=_skin_photo_system_prompt(),
response_mime_type="application/json", response_schema=_SkinAnalysisOut,
response_schema=_SkinAnalysisOut, max_output_tokens=2048,
max_output_tokens=2048, ),
temperature=0.0, user_input=image_summary,
), )
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Gemini API error: {e}")
try: try:
parsed = json.loads(response.text) parsed = json.loads(response.text)

View file

@ -1,12 +1,53 @@
"""Shared helpers for Gemini API access.""" """Shared helpers for Gemini API access."""
import os import os
import time
from collections.abc import Callable
from contextlib import suppress
from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from google import genai 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]: 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") raise HTTPException(status_code=503, detail="GEMINI_API_KEY not configured")
model = os.environ.get("GEMINI_MODEL", _DEFAULT_MODEL) model = os.environ.get("GEMINI_MODEL", _DEFAULT_MODEL)
return genai.Client(api_key=api_key), 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 .domain import Domain
from .enums import ( from .enums import (
AbsorptionSpeed, AbsorptionSpeed,
@ -41,6 +42,8 @@ from .skincare import (
) )
__all__ = [ __all__ = [
# ai logs
"AICallLog",
# domain # domain
"Domain", "Domain",
# enums # 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 contextlib import asynccontextmanager
from typing import AsyncIterator
from dotenv import load_dotenv 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 import FastAPI # noqa: E402
from fastapi.middleware.cors import CORSMiddleware # 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 db import create_db_and_tables # noqa: E402
from innercontext.api import ( # noqa: E402 from innercontext.api import ( # noqa: E402
ai_logs,
health, health,
inventory, inventory,
products, products,
routines, routines,
skincare, skincare,
) )
from innercontext.mcp_server import mcp # noqa: E402
mcp_app = mcp.http_app(path="/mcp")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI) -> AsyncIterator[None]:
create_db_and_tables() create_db_and_tables()
yield yield
app = FastAPI( app = FastAPI(
title="innercontext API", title="innercontext API",
lifespan=combine_lifespans(lifespan, mcp_app.lifespan), lifespan=lifespan,
redirect_slashes=False, 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(health.router, prefix="/health", tags=["health"])
app.include_router(routines.router, prefix="/routines", tags=["routines"]) app.include_router(routines.router, prefix="/routines", tags=["routines"])
app.include_router(skincare.router, prefix="/skincare", tags=["skincare"]) app.include_router(skincare.router, prefix="/skincare", tags=["skincare"])
app.include_router(ai_logs.router, prefix="/ai-logs", tags=["ai-logs"])
app.mount("/mcp", mcp_app)
@app.get("/health-check") @app.get("/health-check")

View file

@ -7,7 +7,6 @@ requires-python = ">=3.12"
dependencies = [ dependencies = [
"alembic>=1.14", "alembic>=1.14",
"fastapi>=0.132.0", "fastapi>=0.132.0",
"fastmcp>=2.0",
"google-genai>=1.65.0", "google-genai>=1.65.0",
"psycopg>=3.3.3", "psycopg>=3.3.3",
"python-dotenv>=1.2.1", "python-dotenv>=1.2.1",
@ -22,6 +21,7 @@ dev = [
"httpx>=0.28.1", "httpx>=0.28.1",
"isort>=8.0.0", "isort>=8.0.0",
"pytest>=9.0.2", "pytest>=9.0.2",
"pytest-cov>=6.0.0",
"ruff>=0.15.2", "ruff>=0.15.2",
"ty>=0.0.18", "ty>=0.0.18",
] ]
@ -29,7 +29,7 @@ dev = [
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
pythonpath = ["."] pythonpath = ["."]
addopts = "-v --tb=short" addopts = "-v --tb=short --cov=innercontext --cov-report=term-missing --cov-report=html"
[tool.isort] [tool.isort]
profile = "black" 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 import os
from contextlib import asynccontextmanager
# Must be set before importing db (which calls create_engine at module level) # Must be set before importing db (which calls create_engine at module level)
os.environ.setdefault("DATABASE_URL", "sqlite://") os.environ.setdefault("DATABASE_URL", "sqlite://")
@ -14,18 +13,6 @@ from db import get_session
from main import app 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() @pytest.fixture()
def session(monkeypatch): def session(monkeypatch):
"""Per-test fresh SQLite in-memory database with full isolation.""" """Per-test fresh SQLite in-memory database with full isolation."""
@ -47,9 +34,6 @@ def session(monkeypatch):
@pytest.fixture() @pytest.fixture()
def client(session, monkeypatch): def client(session, monkeypatch):
"""TestClient using the per-test session for every request.""" """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(): def _override():
yield session 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): def test_create_inventory_product_not_found(client):
r = client.post(f"/products/{uuid.uuid4()}/inventory", json={}) r = client.post(f"/products/{uuid.uuid4()}/inventory", json={})
assert r.status_code == 404 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 import uuid
from unittest.mock import patch
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routines # Routines
@ -216,3 +217,82 @@ def test_delete_grooming_schedule(client):
def test_delete_grooming_schedule_not_found(client): def test_delete_grooming_schedule_not_found(client):
r = client.delete(f"/routines/grooming-schedule/{uuid.uuid4()}") r = client.delete(f"/routines/grooming-schedule/{uuid.uuid4()}")
assert r.status_code == 404 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 revision = 3
requires-python = ">=3.12" 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]] [[package]]
name = "alembic" name = "alembic"
version = "1.18.4" 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" }, { 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]] [[package]]
name = "black" name = "black"
version = "26.1.0" 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" }, { 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]] [[package]]
name = "certifi" name = "certifi"
version = "2026.2.25" 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" }, { 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]] [[package]]
name = "cryptography" name = "cryptography"
version = "46.0.5" 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" }, { 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]] [[package]]
name = "distro" name = "distro"
version = "1.9.0" 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" }, { 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]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.132.0" 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" }, { 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]] [[package]]
name = "google-auth" name = "google-auth"
version = "2.48.0" 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" }, { 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]] [[package]]
name = "idna" name = "idna"
version = "3.11" 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" }, { 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]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.3.0" version = "2.3.0"
@ -656,7 +555,6 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "fastmcp" },
{ name = "google-genai" }, { name = "google-genai" },
{ name = "psycopg" }, { name = "psycopg" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
@ -671,6 +569,7 @@ dev = [
{ name = "httpx" }, { name = "httpx" },
{ name = "isort" }, { name = "isort" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" }, { name = "ruff" },
{ name = "ty" }, { name = "ty" },
] ]
@ -679,7 +578,6 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.14" }, { name = "alembic", specifier = ">=1.14" },
{ name = "fastapi", specifier = ">=0.132.0" }, { name = "fastapi", specifier = ">=0.132.0" },
{ name = "fastmcp", specifier = ">=2.0" },
{ name = "google-genai", specifier = ">=1.65.0" }, { name = "google-genai", specifier = ">=1.65.0" },
{ name = "psycopg", specifier = ">=3.3.3" }, { name = "psycopg", specifier = ">=3.3.3" },
{ name = "python-dotenv", specifier = ">=1.2.1" }, { name = "python-dotenv", specifier = ">=1.2.1" },
@ -694,6 +592,7 @@ dev = [
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "isort", specifier = ">=8.0.0" }, { name = "isort", specifier = ">=8.0.0" },
{ name = "pytest", specifier = ">=9.0.2" }, { name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest-cov", specifier = ">=6.0.0" },
{ name = "ruff", specifier = ">=0.15.2" }, { name = "ruff", specifier = ">=0.15.2" },
{ name = "ty", specifier = ">=0.0.18" }, { 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" }, { 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]] [[package]]
name = "mako" name = "mako"
version = "1.3.10" 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" }, { 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]] [[package]]
name = "markupsafe" name = "markupsafe"
version = "3.0.3" 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" }, { 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]] [[package]]
name = "mypy-extensions" name = "mypy-extensions"
version = "1.1.0" 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" }, { 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]] [[package]]
name = "packaging" name = "packaging"
version = "26.0" 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" }, { 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]] [[package]]
name = "pathspec" name = "pathspec"
version = "1.0.4" 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" }, { 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]] [[package]]
name = "pyasn1" name = "pyasn1"
version = "0.6.2" 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" }, { 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]] [[package]]
name = "pydantic-core" name = "pydantic-core"
version = "2.41.5" 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" }, { 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]] [[package]]
name = "pygments" name = "pygments"
version = "2.19.2" 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" }, { 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]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.2" 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" }, { 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]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.1" 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" }, { 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]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" 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" }, { 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]] [[package]]
name = "requests" name = "requests"
version = "2.32.5" 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" }, { 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]] [[package]]
name = "rsa" name = "rsa"
version = "4.9.1" 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" }, { 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]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" 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" }, { 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]] [[package]]
name = "starlette" name = "starlette"
version = "0.52.1" 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/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" }, { 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 │ │ reverse proxy │────────────▶│ nginx :80 │
│ innercontext.lan → * │ │ /api/* → uvicorn :8000/* │ │ 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 ## 1. Prerequisites
- Proxmox VE host with an existing PostgreSQL LXC and a reverse proxy - 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 ```bash
apt update && apt upgrade -y 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 ### 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 ### 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 ```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
. "$HOME/.nvm/nvm.sh" . "$HOME/.nvm/nvm.sh"
@ -75,16 +83,14 @@ nvm install 24
Copy `node` to `/usr/local/bin` so it is accessible system-wide Copy `node` to `/usr/local/bin` so it is accessible system-wide
(required for `sudo -u innercontext` and for systemd). (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: Use `--remove-destination` to replace any existing symlink with a real file:
```bash ```bash
cp --remove-destination "$(nvm which current)" /usr/local/bin/node cp --remove-destination "$(nvm which current)" /usr/local/bin/node
``` ```
Install pnpm as a standalone binary from GitHub releases — self-contained, Install pnpm as a standalone binary — self-contained, no wrapper scripts,
no wrapper scripts, works system-wide. Do **not** use `corepack enable pnpm` works system-wide:
(the shim requires its nvm directory structure and breaks when copied/linked):
```bash ```bash
curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linux-x64" \ 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 The frontend is **built locally and uploaded** via `deploy.sh` — never built on the server.
cd /opt/innercontext/frontend This section only covers the one-time server-side configuration.
```
### Create `.env.production` ### Create `.env.production`
@ -211,25 +216,24 @@ chmod 600 /opt/innercontext/frontend/.env.production
chown innercontext:innercontext /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 ```bash
sudo -u innercontext bash -c ' cat > /etc/sudoers.d/innercontext-deploy << 'EOF'
cd /opt/innercontext/frontend innercontext ALL=(root) NOPASSWD: \
pnpm install /usr/bin/systemctl restart innercontext, \
PUBLIC_API_BASE=http://innercontext.lan/api pnpm build /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 ### Install systemd service
```bash ```bash
cp /opt/innercontext/systemd/innercontext-node.service /etc/systemd/system/ cp /opt/innercontext/systemd/innercontext-node.service /etc/systemd/system/
systemctl daemon-reload systemctl daemon-reload
systemctl enable --now innercontext-node systemctl enable innercontext-node
systemctl status 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 ```bash
# From any machine on the LAN: # From any machine on the LAN:
curl http://innercontext.lan/api/health-check # {"status":"ok"} curl http://innercontext.lan/api/health-check # {"status":"ok"}
curl http://innercontext.lan/api/products # [] curl http://innercontext.lan/api/products # []
curl http://innercontext.lan/ # SvelteKit HTML shell 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`. The web UI should be accessible at `http://innercontext.lan`.
--- ---
## 11. Updating the application ## 12. Updating the application
```bash ```bash
cd /opt/innercontext # From the repo root on your local machine:
git pull ./deploy.sh # full deploy (frontend + backend)
./deploy.sh frontend # frontend only
# Sync backend dependencies if pyproject.toml changed: ./deploy.sh backend # backend only
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
``` ```
--- ---
## 12. Troubleshooting ## 13. Troubleshooting
### 502 Bad Gateway on `/api/*` ### 502 Bad Gateway on `/api/*`
@ -328,17 +352,7 @@ journalctl -u innercontext -n 50
```bash ```bash
systemctl status innercontext-node systemctl status innercontext-node
journalctl -u innercontext-node -n 50 journalctl -u innercontext-node -n 50
# Verify /opt/innercontext/frontend/build/index.js exists (pnpm build ran successfully) # Verify /opt/innercontext/frontend/build/index.js exists (deploy.sh 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
``` ```
### Database connection refused ### 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 ## Environment variables
| Variable | Description | Default | | Variable | Description | Default |
|---|---|---| | ----------------- | ------------------------------- | ----------------------- |
| `PUBLIC_API_BASE` | Base URL of the FastAPI backend | `http://localhost:8000` | | `PUBLIC_API_BASE` | Base URL of the FastAPI backend | `http://localhost:8000` |
Set `PUBLIC_API_BASE` at **build time** for production: Set `PUBLIC_API_BASE` at **build time** for production:
@ -51,24 +51,24 @@ Or use the provided systemd service: `../systemd/innercontext-node.service`.
## Routes ## Routes
| Route | Description | | Route | Description |
|---|---| | --------------------- | ------------------------ |
| `/` | Dashboard | | `/` | Dashboard |
| `/products` | Product list | | `/products` | Product list |
| `/products/new` | Add product | | `/products/new` | Add product |
| `/products/[id]` | Product detail / edit | | `/products/[id]` | Product detail / edit |
| `/routines` | Routine list | | `/routines` | Routine list |
| `/routines/new` | Create routine | | `/routines/new` | Create routine |
| `/routines/[id]` | Routine detail | | `/routines/[id]` | Routine detail |
| `/health/medications` | Medications | | `/health/medications` | Medications |
| `/health/lab-results` | Lab results | | `/health/lab-results` | Lab results |
| `/skin` | Skin condition snapshots | | `/skin` | Skin condition snapshots |
## Key files ## Key files
| File | Purpose | | File | Purpose |
|---|---| | ------------------ | --------------------------------- |
| `src/lib/api.ts` | API client (typed fetch wrappers) | | `src/lib/api.ts` | API client (typed fetch wrappers) |
| `src/lib/types.ts` | Shared TypeScript types | | `src/lib/types.ts` | Shared TypeScript types |
| `src/app.css` | Tailwind v4 theme + global styles | | `src/app.css` | Tailwind v4 theme + global styles |
| `svelte.config.js` | SvelteKit config (adapter-node) | | `svelte.config.js` | SvelteKit config (adapter-node) |

View file

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

View file

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

View file

@ -1,36 +1,47 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
}, "lint": "eslint .",
"devDependencies": { "format": "prettier --write ."
"@internationalized/date": "^3.11.0", },
"@lucide/svelte": "^0.561.0", "devDependencies": {
"@sveltejs/adapter-node": "^5.0.0", "@eslint/js": "^10.0.1",
"@sveltejs/kit": "^2.50.2", "@internationalized/date": "^3.11.0",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@lucide/svelte": "^0.561.0",
"@tailwindcss/vite": "^4.2.1", "@sveltejs/adapter-node": "^5.0.0",
"svelte": "^5.51.0", "@sveltejs/kit": "^2.50.2",
"svelte-check": "^4.3.6", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"tailwind-variants": "^3.2.2", "@tailwindcss/vite": "^4.2.1",
"tailwindcss": "^4.2.1", "eslint": "^10.0.2",
"typescript": "^5.9.3", "eslint-plugin-svelte": "^3.15.0",
"vite": "^7.3.1" "globals": "^17.4.0",
}, "prettier": "^3.8.1",
"dependencies": { "prettier-plugin-svelte": "^3.5.0",
"@inlang/paraglide-js": "^2.13.0", "svelte": "^5.51.0",
"bits-ui": "^2.16.2", "svelte-check": "^4.3.6",
"clsx": "^2.1.1", "svelte-eslint-parser": "^1.5.1",
"lucide-svelte": "^0.575.0", "tailwind-variants": "^3.2.2",
"mode-watcher": "^1.1.0", "tailwindcss": "^4.2.1",
"tailwind-merge": "^3.5.0" "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", "$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "pl", "baseLocale": "pl",
"locales": ["pl", "en"], "locales": ["pl", "en"],
"modules": [ "modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", "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" "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
], ],
"plugin.inlang.messageFormat": { "plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json" "pathPattern": "./messages/{locale}.json"
} }
} }

View file

@ -5,85 +5,85 @@
/* ── CSS variable definitions (light / dark) ─────────────────────────────── */ /* ── CSS variable definitions (light / dark) ─────────────────────────────── */
:root { :root {
--background: hsl(0 0% 100%); --background: hsl(0 0% 100%);
--foreground: hsl(240 10% 3.9%); --foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%); --card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%); --card-foreground: hsl(240 10% 3.9%);
--popover: hsl(0 0% 100%); --popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%); --popover-foreground: hsl(240 10% 3.9%);
--primary: hsl(240 5.9% 10%); --primary: hsl(240 5.9% 10%);
--primary-foreground: hsl(0 0% 98%); --primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%); --secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%); --secondary-foreground: hsl(240 5.9% 10%);
--muted: hsl(240 4.8% 95.9%); --muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%); --muted-foreground: hsl(240 3.8% 46.1%);
--accent: hsl(240 4.8% 95.9%); --accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%); --accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 84.2% 60.2%); --destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%); --destructive-foreground: hsl(0 0% 98%);
--border: hsl(240 5.9% 90%); --border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%); --input: hsl(240 5.9% 90%);
--ring: hsl(240 5.9% 10%); --ring: hsl(240 5.9% 10%);
--radius: 0.5rem; --radius: 0.5rem;
} }
.dark { .dark {
--background: hsl(240 10% 3.9%); --background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%); --foreground: hsl(0 0% 98%);
--card: hsl(240 10% 3.9%); --card: hsl(240 10% 3.9%);
--card-foreground: hsl(0 0% 98%); --card-foreground: hsl(0 0% 98%);
--popover: hsl(240 10% 3.9%); --popover: hsl(240 10% 3.9%);
--popover-foreground: hsl(0 0% 98%); --popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%); --primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%); --primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(240 3.7% 15.9%); --secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%); --secondary-foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%); --muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%); --muted-foreground: hsl(240 5% 64.9%);
--accent: hsl(240 3.7% 15.9%); --accent: hsl(240 3.7% 15.9%);
--accent-foreground: hsl(0 0% 98%); --accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%); --destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%); --destructive-foreground: hsl(0 0% 98%);
--border: hsl(240 3.7% 15.9%); --border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%); --input: hsl(240 3.7% 15.9%);
--ring: hsl(240 4.9% 83.9%); --ring: hsl(240 4.9% 83.9%);
} }
/* ── Map CSS vars → Tailwind v4 design tokens ────────────────────────────── */ /* ── Map CSS vars → Tailwind v4 design tokens ────────────────────────────── */
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
} }
/* ── Base resets ─────────────────────────────────────────────────────────── */ /* ── Base resets ─────────────────────────────────────────────────────────── */
* { * {
border-color: var(--border); border-color: var(--border);
} }
body { body {
background-color: var(--background); background-color: var(--background);
color: var(--foreground); 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 // See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces // for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
// interface Locals {} // interface Locals {}
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}
} }
} }
export {}; export {};

View file

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

View file

@ -1,6 +1,6 @@
import { paraglideMiddleware } from '$lib/paraglide/server.js'; import { paraglideMiddleware } from "$lib/paraglide/server.js";
import type { Handle } from '@sveltejs/kit'; import type { Handle } from "@sveltejs/kit";
export const handle: Handle = async ({ event, resolve }) => { 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 { import type {
ActiveIngredient, ActiveIngredient,
BatchSuggestion, BatchSuggestion,
GroomingSchedule, GroomingSchedule,
LabResult, LabResult,
MedicationEntry, MedicationEntry,
MedicationUsage, MedicationUsage,
PartOfDay, PartOfDay,
Product, Product,
ProductContext, ProductContext,
ProductEffectProfile, ProductEffectProfile,
ProductInteraction, ProductInteraction,
ProductInventory, ProductInventory,
Routine, Routine,
RoutineSuggestion, RoutineSuggestion,
RoutineStep, RoutineStep,
SkinConditionSnapshot SkinConditionSnapshot,
} from './types'; } from "./types";
// ─── Core fetch helpers ────────────────────────────────────────────────────── // ─── Core fetch helpers ──────────────────────────────────────────────────────
async function request<T>(path: string, init: RequestInit = {}): Promise<T> { async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const url = `${PUBLIC_API_BASE}${path}`; // Server-side uses PUBLIC_API_BASE (e.g. http://localhost:8000).
const res = await fetch(url, { // Browser-side uses /api so nginx proxies the request on the correct host.
headers: { 'Content-Type': 'application/json', ...init.headers }, const base = browser ? "/api" : PUBLIC_API_BASE;
...init const url = `${base}${path}`;
}); const res = await fetch(url, {
if (!res.ok) { headers: { "Content-Type": "application/json", ...init.headers },
const detail = await res.json().catch(() => ({ detail: res.statusText })); ...init,
throw new Error(detail?.detail ?? res.statusText); });
} if (!res.ok) {
if (res.status === 204) return undefined as T; const detail = await res.json().catch(() => ({ detail: res.statusText }));
return res.json(); throw new Error(detail?.detail ?? res.statusText);
}
if (res.status === 204) return undefined as T;
return res.json();
} }
export const api = { export const api = {
get: <T>(path: string) => request<T>(path), get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) => post: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) }), request<T>(path, { method: "POST", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) => patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }), request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
del: (path: string) => request<void>(path, { method: 'DELETE' }) del: (path: string) => request<void>(path, { method: "DELETE" }),
}; };
// ─── Products ──────────────────────────────────────────────────────────────── // ─── Products ────────────────────────────────────────────────────────────────
export interface ProductListParams { export interface ProductListParams {
category?: string; category?: string;
brand?: string; brand?: string;
targets?: string[]; targets?: string[];
is_medication?: boolean; is_medication?: boolean;
is_tool?: boolean; is_tool?: boolean;
} }
export function getProducts(params: ProductListParams = {}): Promise<Product[]> { export function getProducts(
const q = new URLSearchParams(); params: ProductListParams = {},
if (params.category) q.set('category', params.category); ): Promise<Product[]> {
if (params.brand) q.set('brand', params.brand); const q = new URLSearchParams();
if (params.targets) params.targets.forEach((t) => q.append('targets', t)); if (params.category) q.set("category", params.category);
if (params.is_medication != null) q.set('is_medication', String(params.is_medication)); if (params.brand) q.set("brand", params.brand);
if (params.is_tool != null) q.set('is_tool', String(params.is_tool)); if (params.targets) params.targets.forEach((t) => q.append("targets", t));
const qs = q.toString(); if (params.is_medication != null)
return api.get(`/products${qs ? `?${qs}` : ''}`); 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 getProduct = (id: string): Promise<Product> =>
export const createProduct = (body: Record<string, unknown>): Promise<Product> => api.get(`/products/${id}`);
api.post('/products', body); export const createProduct = (
export const updateProduct = (id: string, body: Record<string, unknown>): Promise<Product> => body: Record<string, unknown>,
api.patch(`/products/${id}`, body); ): Promise<Product> => api.post("/products", body);
export const deleteProduct = (id: string): Promise<void> => api.del(`/products/${id}`); 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[]> => export const getInventory = (productId: string): Promise<ProductInventory[]> =>
api.get(`/products/${productId}/inventory`); api.get(`/products/${productId}/inventory`);
export const createInventory = ( export const createInventory = (
productId: string, productId: string,
body: Record<string, unknown> body: Record<string, unknown>,
): Promise<ProductInventory> => api.post(`/products/${productId}/inventory`, body); ): Promise<ProductInventory> =>
export const updateInventory = (id: string, body: Record<string, unknown>): Promise<ProductInventory> => api.post(`/products/${productId}/inventory`, body);
api.patch(`/inventory/${id}`, body); export const updateInventory = (
export const deleteInventory = (id: string): Promise<void> => api.del(`/inventory/${id}`); 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 { export interface ProductParseResponse {
name?: string; brand?: string; line_name?: string; sku?: string; url?: string; barcode?: string; name?: string;
category?: string; recommended_time?: string; texture?: string; absorption_speed?: string; brand?: string;
leave_on?: boolean; price_tier?: string; line_name?: string;
size_ml?: number; full_weight_g?: number; empty_weight_g?: number; pao_months?: number; sku?: string;
inci?: string[]; actives?: ActiveIngredient[]; url?: string;
recommended_for?: string[]; targets?: string[]; barcode?: string;
contraindications?: string[]; usage_notes?: string; category?: string;
fragrance_free?: boolean; essential_oils_free?: boolean; recommended_time?: string;
alcohol_denat_free?: boolean; pregnancy_safe?: boolean; texture?: string;
product_effect_profile?: ProductEffectProfile; absorption_speed?: string;
ph_min?: number; ph_max?: number; leave_on?: boolean;
incompatible_with?: ProductInteraction[]; synergizes_with?: string[]; price_tier?: string;
context_rules?: ProductContext; size_ml?: number;
min_interval_hours?: number; max_frequency_per_week?: number; full_weight_g?: number;
is_medication?: boolean; is_tool?: boolean; needle_length_mm?: 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> => export const parseProductText = (text: string): Promise<ProductParseResponse> =>
api.post('/products/parse-text', { text }); api.post("/products/parse-text", { text });
// ─── Routines ──────────────────────────────────────────────────────────────── // ─── Routines ────────────────────────────────────────────────────────────────
export interface RoutineListParams { export interface RoutineListParams {
from_date?: string; from_date?: string;
to_date?: string; to_date?: string;
part_of_day?: string; part_of_day?: string;
} }
export function getRoutines(params: RoutineListParams = {}): Promise<Routine[]> { export function getRoutines(
const q = new URLSearchParams(); params: RoutineListParams = {},
if (params.from_date) q.set('from_date', params.from_date); ): Promise<Routine[]> {
if (params.to_date) q.set('to_date', params.to_date); const q = new URLSearchParams();
if (params.part_of_day) q.set('part_of_day', params.part_of_day); if (params.from_date) q.set("from_date", params.from_date);
const qs = q.toString(); if (params.to_date) q.set("to_date", params.to_date);
return api.get(`/routines${qs ? `?${qs}` : ''}`); 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 getRoutine = (id: string): Promise<Routine> =>
export const createRoutine = (body: Record<string, unknown>): Promise<Routine> => api.get(`/routines/${id}`);
api.post('/routines', body); export const createRoutine = (
export const updateRoutine = (id: string, body: Record<string, unknown>): Promise<Routine> => body: Record<string, unknown>,
api.patch(`/routines/${id}`, body); ): Promise<Routine> => api.post("/routines", body);
export const deleteRoutine = (id: string): Promise<void> => api.del(`/routines/${id}`); 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> => export const addRoutineStep = (
api.post(`/routines/${routineId}/steps`, body); routineId: string,
export const updateRoutineStep = (stepId: string, body: Record<string, unknown>): Promise<RoutineStep> => body: Record<string, unknown>,
api.patch(`/routines/steps/${stepId}`, body); ): 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> => export const deleteRoutineStep = (stepId: string): Promise<void> =>
api.del(`/routines/steps/${stepId}`); api.del(`/routines/steps/${stepId}`);
export const suggestRoutine = (body: { export const suggestRoutine = (body: {
routine_date: string; routine_date: string;
part_of_day: PartOfDay; part_of_day: PartOfDay;
notes?: string; notes?: string;
}): Promise<RoutineSuggestion> => api.post('/routines/suggest', body); include_minoxidil_beard?: boolean;
leaving_home?: boolean;
}): Promise<RoutineSuggestion> => api.post("/routines/suggest", body);
export const suggestBatch = (body: { export const suggestBatch = (body: {
from_date: string; from_date: string;
to_date: string; to_date: string;
notes?: string; notes?: string;
}): Promise<BatchSuggestion> => api.post('/routines/suggest-batch', body); include_minoxidil_beard?: boolean;
minimize_products?: boolean;
}): Promise<BatchSuggestion> => api.post("/routines/suggest-batch", body);
export const getGroomingSchedule = (): Promise<GroomingSchedule[]> => export const getGroomingSchedule = (): Promise<GroomingSchedule[]> =>
api.get('/routines/grooming-schedule'); api.get("/routines/grooming-schedule");
export const createGroomingScheduleEntry = (body: Record<string, unknown>): Promise<GroomingSchedule> => export const createGroomingScheduleEntry = (
api.post('/routines/grooming-schedule', body); body: Record<string, unknown>,
export const updateGroomingScheduleEntry = (id: string, body: Record<string, unknown>): Promise<GroomingSchedule> => ): Promise<GroomingSchedule> => api.post("/routines/grooming-schedule", body);
api.patch(`/routines/grooming-schedule/${id}`, 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> => export const deleteGroomingScheduleEntry = (id: string): Promise<void> =>
api.del(`/routines/grooming-schedule/${id}`); api.del(`/routines/grooming-schedule/${id}`);
// ─── Health Medications ──────────────────────────────────────────────────── // ─── Health Medications ────────────────────────────────────────────────────
export interface MedicationListParams { export interface MedicationListParams {
kind?: string; kind?: string;
product_name?: string; product_name?: string;
} }
export function getMedications(params: MedicationListParams = {}): Promise<MedicationEntry[]> { export function getMedications(
const q = new URLSearchParams(); params: MedicationListParams = {},
if (params.kind) q.set('kind', params.kind); ): Promise<MedicationEntry[]> {
if (params.product_name) q.set('product_name', params.product_name); const q = new URLSearchParams();
const qs = q.toString(); if (params.kind) q.set("kind", params.kind);
return api.get(`/health/medications${qs ? `?${qs}` : ''}`); 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> => export const getMedication = (id: string): Promise<MedicationEntry> =>
api.get(`/health/medications/${id}`); api.get(`/health/medications/${id}`);
export const createMedication = (body: Record<string, unknown>): Promise<MedicationEntry> => export const createMedication = (
api.post('/health/medications', body); body: Record<string, unknown>,
): Promise<MedicationEntry> => api.post("/health/medications", body);
export const updateMedication = ( export const updateMedication = (
id: string, id: string,
body: Record<string, unknown> body: Record<string, unknown>,
): Promise<MedicationEntry> => api.patch(`/health/medications/${id}`, body); ): Promise<MedicationEntry> => api.patch(`/health/medications/${id}`, body);
export const deleteMedication = (id: string): Promise<void> => export const deleteMedication = (id: string): Promise<void> =>
api.del(`/health/medications/${id}`); api.del(`/health/medications/${id}`);
export const getMedicationUsages = (medicationId: string): Promise<MedicationUsage[]> => export const getMedicationUsages = (
api.get(`/health/medications/${medicationId}/usages`); medicationId: string,
): Promise<MedicationUsage[]> =>
api.get(`/health/medications/${medicationId}/usages`);
export const createMedicationUsage = ( export const createMedicationUsage = (
medicationId: string, medicationId: string,
body: Record<string, unknown> body: Record<string, unknown>,
): Promise<MedicationUsage> => api.post(`/health/medications/${medicationId}/usages`, body); ): Promise<MedicationUsage> =>
api.post(`/health/medications/${medicationId}/usages`, body);
// ─── Health Lab results ──────────────────────────────────────────────────── // ─── Health Lab results ────────────────────────────────────────────────────
export interface LabResultListParams { export interface LabResultListParams {
test_code?: string; test_code?: string;
flag?: string; flag?: string;
lab?: string; lab?: string;
from_date?: string; from_date?: string;
to_date?: string; to_date?: string;
} }
export function getLabResults(params: LabResultListParams = {}): Promise<LabResult[]> { export function getLabResults(
const q = new URLSearchParams(); params: LabResultListParams = {},
if (params.test_code) q.set('test_code', params.test_code); ): Promise<LabResult[]> {
if (params.flag) q.set('flag', params.flag); const q = new URLSearchParams();
if (params.lab) q.set('lab', params.lab); if (params.test_code) q.set("test_code", params.test_code);
if (params.from_date) q.set('from_date', params.from_date); if (params.flag) q.set("flag", params.flag);
if (params.to_date) q.set('to_date', params.to_date); if (params.lab) q.set("lab", params.lab);
const qs = q.toString(); if (params.from_date) q.set("from_date", params.from_date);
return api.get(`/health/lab-results${qs ? `?${qs}` : ''}`); 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> => export const getLabResult = (id: string): Promise<LabResult> =>
api.get(`/health/lab-results/${id}`); api.get(`/health/lab-results/${id}`);
export const createLabResult = (body: Record<string, unknown>): Promise<LabResult> => export const createLabResult = (
api.post('/health/lab-results', body); body: Record<string, unknown>,
export const updateLabResult = (id: string, body: Record<string, unknown>): Promise<LabResult> => ): Promise<LabResult> => api.post("/health/lab-results", body);
api.patch(`/health/lab-results/${id}`, 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> => export const deleteLabResult = (id: string): Promise<void> =>
api.del(`/health/lab-results/${id}`); api.del(`/health/lab-results/${id}`);
// ─── Skin ──────────────────────────────────────────────────────────────────── // ─── Skin ────────────────────────────────────────────────────────────────────
export interface SnapshotListParams { export interface SnapshotListParams {
from_date?: string; from_date?: string;
to_date?: string; to_date?: string;
overall_state?: string; overall_state?: string;
} }
export function getSkinSnapshots(params: SnapshotListParams = {}): Promise<SkinConditionSnapshot[]> { export function getSkinSnapshots(
const q = new URLSearchParams(); params: SnapshotListParams = {},
if (params.from_date) q.set('from_date', params.from_date); ): Promise<SkinConditionSnapshot[]> {
if (params.to_date) q.set('to_date', params.to_date); const q = new URLSearchParams();
if (params.overall_state) q.set('overall_state', params.overall_state); if (params.from_date) q.set("from_date", params.from_date);
const qs = q.toString(); if (params.to_date) q.set("to_date", params.to_date);
return api.get(`/skincare${qs ? `?${qs}` : ''}`); 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> => export const getSkinSnapshot = (id: string): Promise<SkinConditionSnapshot> =>
api.get(`/skincare/${id}`); api.get(`/skincare/${id}`);
export const createSkinSnapshot = (body: Record<string, unknown>): Promise<SkinConditionSnapshot> => export const createSkinSnapshot = (
api.post('/skincare', body); body: Record<string, unknown>,
): Promise<SkinConditionSnapshot> => api.post("/skincare", body);
export const updateSkinSnapshot = ( export const updateSkinSnapshot = (
id: string, id: string,
body: Record<string, unknown> body: Record<string, unknown>,
): Promise<SkinConditionSnapshot> => api.patch(`/skincare/${id}`, body); ): 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 { export interface SkinPhotoAnalysisResponse {
overall_state?: string; overall_state?: string;
texture?: string; texture?: string;
skin_type?: string; skin_type?: string;
hydration_level?: number; hydration_level?: number;
sebum_tzone?: number; sebum_tzone?: number;
sebum_cheeks?: number; sebum_cheeks?: number;
sensitivity_level?: number; sensitivity_level?: number;
barrier_state?: string; barrier_state?: string;
active_concerns?: string[]; active_concerns?: string[];
risks?: string[]; risks?: string[];
priorities?: string[]; priorities?: string[];
notes?: string; notes?: string;
} }
export async function analyzeSkinPhotos(files: File[]): Promise<SkinPhotoAnalysisResponse> { export async function analyzeSkinPhotos(
const body = new FormData(); files: File[],
for (const file of files) body.append('photos', file); ): Promise<SkinPhotoAnalysisResponse> {
const res = await fetch(`${PUBLIC_API_BASE}/skincare/analyze-photos`, { method: 'POST', body }); const body = new FormData();
if (!res.ok) { for (const file of files) body.append("photos", file);
const detail = await res.json().catch(() => ({ detail: res.statusText })); const base = browser ? "/api" : PUBLIC_API_BASE;
throw new Error(detail?.detail ?? res.statusText); const res = await fetch(`${base}/skincare/analyze-photos`, {
} method: "POST",
return res.json(); 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> <Card>
<CardHeader><CardTitle>{m["productForm_basicInfo"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["productForm_basicInfo"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4"> <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"> <div class="space-y-2">
<Label for="name">{m["productForm_name"]()}</Label> <Label for="name">{m["productForm_name"]()}</Label>
<Input id="name" name="name" required placeholder={m["productForm_namePlaceholder"]()} bind:value={name} /> <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} /> <Input id="brand" name="brand" required placeholder={m["productForm_brandPlaceholder"]()} bind:value={brand} />
</div> </div>
</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"> <div class="space-y-2">
<Label for="line_name">{m["productForm_lineName"]()}</Label> <Label for="line_name">{m["productForm_lineName"]()}</Label>
<Input id="line_name" name="line_name" placeholder={m["productForm_lineNamePlaceholder"]()} bind:value={lineName} /> <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} /> <Input id="url" name="url" type="url" placeholder="https://…" bind:value={url} />
</div> </div>
</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"> <div class="space-y-2">
<Label for="sku">{m["productForm_sku"]()}</Label> <Label for="sku">{m["productForm_sku"]()}</Label>
<Input id="sku" name="sku" placeholder={m["productForm_skuPlaceholder"]()} bind:value={sku} /> <Input id="sku" name="sku" placeholder={m["productForm_skuPlaceholder"]()} bind:value={sku} />
@ -488,7 +488,7 @@
<Card> <Card>
<CardHeader><CardTitle>{m["productForm_classification"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["productForm_classification"]()}</CardTitle></CardHeader>
<CardContent> <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"> <div class="col-span-2 space-y-2">
<Label>{m["productForm_category"]()}</Label> <Label>{m["productForm_category"]()}</Label>
<input type="hidden" name="category" value={category} /> <input type="hidden" name="category" value={category} />
@ -564,7 +564,7 @@
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<Label>{m["productForm_recommendedFor"]()}</Label> <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} {#each skinTypes as st}
<label class="flex cursor-pointer items-center gap-2 text-sm"> <label class="flex cursor-pointer items-center gap-2 text-sm">
<input <input
@ -588,7 +588,7 @@
<div class="space-y-2"> <div class="space-y-2">
<Label>{m["productForm_targetConcerns"]()}</Label> <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} {#each skinConcerns as sc}
<label class="flex cursor-pointer items-center gap-2 text-sm"> <label class="flex cursor-pointer items-center gap-2 text-sm">
<input <input
@ -641,7 +641,7 @@
</div> </div>
<div class="space-y-3"> <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> <Label>{m["productForm_activeIngredients"]()}</Label>
<Button type="button" variant="outline" size="sm" onclick={addActive}>{m["productForm_addActive"]()}</Button> <Button type="button" variant="outline" size="sm" onclick={addActive}>{m["productForm_addActive"]()}</Button>
</div> </div>
@ -650,14 +650,23 @@
{#each actives as active, i} {#each actives as active, i}
<div class="rounded-md border border-border p-3 space-y-3"> <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="flex items-end gap-2">
<div class="space-y-1"> <div class="min-w-0 flex-1 space-y-1">
<Label class="text-xs">{m["productForm_activeName"]()}</Label> <Label class="text-xs">{m["productForm_activeName"]()}</Label>
<Input <Input
placeholder="e.g. Niacinamide" placeholder="e.g. Niacinamide"
bind:value={active.name} bind:value={active.name}
/> />
</div> </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"> <div class="space-y-1">
<Label class="text-xs">{m["productForm_activePercent"]()}</Label> <Label class="text-xs">{m["productForm_activePercent"]()}</Label>
<Input <Input
@ -687,18 +696,11 @@
<option value="3">{m["productForm_strengthHigh"]()}</option> <option value="3">{m["productForm_strengthHigh"]()}</option>
</select> </select>
</div> </div>
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => removeActive(i)}
class="text-destructive hover:text-destructive"
>✕</Button>
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<Label class="text-xs text-muted-foreground">{m["productForm_activeFunctions"]()}</Label> <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} {#each ingFunctions as fn}
<label class="flex cursor-pointer items-center gap-1.5 text-xs"> <label class="flex cursor-pointer items-center gap-1.5 text-xs">
<input <input
@ -764,7 +766,7 @@
</div> </div>
<div class="space-y-3"> <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> <Label>{m["productForm_incompatibleWith"]()}</Label>
<Button type="button" variant="outline" size="sm" onclick={addIncompatible}> <Button type="button" variant="outline" size="sm" onclick={addIncompatible}>
{m["productForm_addIncompatibility"]()} {m["productForm_addIncompatibility"]()}
@ -774,7 +776,7 @@
<input type="hidden" name="incompatible_with_json" value={incompatibleJson} /> <input type="hidden" name="incompatible_with_json" value={incompatibleJson} />
{#each incompatibleWith as row, i} {#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"> <div class="space-y-1">
<Label class="text-xs">{m["productForm_incompTarget"]()}</Label> <Label class="text-xs">{m["productForm_incompTarget"]()}</Label>
<Input placeholder="e.g. Vitamin C" bind:value={row.target} /> <Input placeholder="e.g. Vitamin C" bind:value={row.target} />
@ -813,7 +815,7 @@
<Card> <Card>
<CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader>
<CardContent> <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"> <div class="space-y-2">
<Label>{m["productForm_ctxAfterShaving"]()}</Label> <Label>{m["productForm_ctxAfterShaving"]()}</Label>
<input type="hidden" name="ctx_safe_after_shaving" value={ctxAfterShaving} /> <input type="hidden" name="ctx_safe_after_shaving" value={ctxAfterShaving} />
@ -884,7 +886,7 @@
<Card> <Card>
<CardHeader><CardTitle>{m["productForm_productDetails"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["productForm_productDetails"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4"> <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"> <div class="space-y-2">
<Label>{m["productForm_priceTier"]()}</Label> <Label>{m["productForm_priceTier"]()}</Label>
<input type="hidden" name="price_tier" value={priceTier} /> <input type="hidden" name="price_tier" value={priceTier} />
@ -919,7 +921,7 @@
</div> </div>
</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"> <div class="space-y-2">
<Label for="ph_min">{m["productForm_phMin"]()}</Label> <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} /> <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> <Card>
<CardHeader><CardTitle>{m["productForm_safetyFlags"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["productForm_safetyFlags"]()}</CardTitle></CardHeader>
<CardContent> <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"> <div class="space-y-2">
<Label>{m["productForm_fragranceFree"]()}</Label> <Label>{m["productForm_fragranceFree"]()}</Label>
<input type="hidden" name="fragrance_free" value={fragranceFree} /> <input type="hidden" name="fragrance_free" value={fragranceFree} />
@ -1008,7 +1010,7 @@
<Card> <Card>
<CardHeader><CardTitle>{m["productForm_usageConstraints"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["productForm_usageConstraints"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4"> <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"> <div class="space-y-2">
<Label for="min_interval_hours">{m["productForm_minIntervalHours"]()}</Label> <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} /> <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, { import Root, {
type ButtonProps, type ButtonProps,
type ButtonSize, type ButtonSize,
type ButtonVariant, type ButtonVariant,
buttonVariants, buttonVariants,
} from "./button.svelte"; } from "./button.svelte";
export { export {
Root, Root,
type ButtonProps as Props, type ButtonProps as Props,
// //
Root as Button, Root as Button,
buttonVariants, buttonVariants,
type ButtonProps, type ButtonProps,
type ButtonSize, type ButtonSize,
type ButtonVariant, type ButtonVariant,
}; };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,19 +1,22 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import { page } from '$app/state'; import { page } from '$app/state';
import { resolve } from '$app/paths';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte'; import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
let { children } = $props(); let { children } = $props();
let mobileMenuOpen = $state(false);
const navItems = $derived([ const navItems = $derived([
{ href: '/', label: m.nav_dashboard(), icon: '🏠' }, { href: resolve('/'), label: m.nav_dashboard(), icon: '🏠' },
{ href: '/products', label: m.nav_products(), icon: '🧴' }, { href: resolve('/products'), label: m.nav_products(), icon: '🧴' },
{ href: '/routines', label: m.nav_routines(), icon: '📋' }, { href: resolve('/routines'), label: m.nav_routines(), icon: '📋' },
{ href: '/routines/grooming-schedule', label: m.nav_grooming(), icon: '🪒' }, { href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: '🪒' },
{ href: '/health/medications', label: m.nav_medications(), icon: '💊' }, { href: resolve('/health/medications'), label: m.nav_medications(), icon: '💊' },
{ href: '/health/lab-results', label: m["nav_labResults"](), icon: '🔬' }, { href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: '🔬' },
{ href: '/skin', label: m.nav_skin(), icon: '✨' } { href: resolve('/skin'), label: m.nav_skin(), icon: '✨' }
]); ]);
function isActive(href: string) { function isActive(href: string) {
@ -28,9 +31,68 @@
} }
</script> </script>
<div class="flex min-h-screen bg-background"> <div class="flex min-h-screen flex-col bg-background md:flex-row">
<!-- Sidebar --> <!-- Mobile header -->
<nav class="w-56 shrink-0 border-r border-border bg-card px-3 py-6"> <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"> <div class="mb-8 px-3">
<h1 class="text-lg font-semibold tracking-tight">{m["nav_appName"]()}</h1> <h1 class="text-lg font-semibold tracking-tight">{m["nav_appName"]()}</h1>
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p> <p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p>
@ -57,7 +119,7 @@
</nav> </nav>
<!-- Main content --> <!-- Main content -->
<main class="flex-1 overflow-auto p-8"> <main class="flex-1 overflow-auto p-4 md:p-8">
{@render children()} {@render children()}
</main> </main>
</div> </div>

View file

@ -1,4 +1,5 @@
import { getRoutines, getSkinSnapshots } from '$lib/api'; import { getRoutines, getSkinSnapshots } from '$lib/api';
import type { SkinConditionSnapshot } from '$lib/types';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
@ -8,7 +9,7 @@ export const load: PageServerLoad = async () => {
]); ]);
return { return {
recentRoutines: routines.slice(0, 10), 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); d.setDate(d.getDate() - daysAgo);
return d.toISOString().slice(0, 10); 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"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
@ -16,7 +17,6 @@
TableHeader, TableHeader,
TableRow TableRow
} from '$lib/components/ui/table'; } from '$lib/components/ui/table';
import { goto } from '$app/navigation';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
@ -33,6 +33,12 @@
let showForm = $state(false); let showForm = $state(false);
let selectedFlag = $state(''); let selectedFlag = $state('');
let filterFlag = $derived(data.flag ?? ''); 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> </script>
<svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head> <svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head>
@ -61,9 +67,7 @@
<Select <Select
type="single" type="single"
value={filterFlag} value={filterFlag}
onValueChange={(v) => { onValueChange={onFlagChange}
goto(v ? `/health/lab-results?flag=${v}` : '/health/lab-results');
}}
> >
<SelectTrigger class="w-32">{filterFlag || m["labResults_flagAll"]()}</SelectTrigger> <SelectTrigger class="w-32">{filterFlag || m["labResults_flagAll"]()}</SelectTrigger>
<SelectContent> <SelectContent>
@ -79,7 +83,7 @@
<Card> <Card>
<CardHeader><CardTitle>{m["labResults_newTitle"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["labResults_newTitle"]()}</CardTitle></CardHeader>
<CardContent> <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"> <div class="space-y-1">
<Label for="collected_at">{m["labResults_date"]()}</Label> <Label for="collected_at">{m["labResults_date"]()}</Label>
<Input id="collected_at" name="collected_at" type="date" required /> <Input id="collected_at" name="collected_at" type="date" required />
@ -125,7 +129,8 @@
</Card> </Card>
{/if} {/if}
<div class="rounded-md border border-border"> <!-- Desktop: table -->
<div class="hidden rounded-md border border-border md:block">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -173,4 +178,34 @@
</TableBody> </TableBody>
</Table> </Table>
</div> </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> </div>

View file

@ -56,7 +56,7 @@
<Card> <Card>
<CardHeader><CardTitle>{m["medications_newTitle"]()}</CardTitle></CardHeader> <CardHeader><CardTitle>{m["medications_newTitle"]()}</CardTitle></CardHeader>
<CardContent> <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"> <div class="space-y-1 col-span-2">
<Label>{m.medications_kind()}</Label> <Label>{m.medications_kind()}</Label>
<input type="hidden" name="kind" value={kind} /> <input type="hidden" name="kind" value={kind} />

View file

@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { Product } from '$lib/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 { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
@ -37,7 +39,7 @@
return bc !== 0 ? bc : a.name.localeCompare(b.name); 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) { for (const p of items) {
if (!map.has(p.category)) map.set(p.category, []); if (!map.has(p.category)) map.set(p.category, []);
map.get(p.category)!.push(p); map.get(p.category)!.push(p);
@ -63,10 +65,13 @@
<h2 class="text-2xl font-bold tracking-tight">{m.products_title()}</h2> <h2 class="text-2xl font-bold tracking-tight">{m.products_title()}</h2>
<p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p> <p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p>
</div> </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>
<div class="flex gap-1"> <div class="flex flex-wrap gap-1">
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)} {#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
<Button <Button
variant={ownershipFilter === f ? 'default' : 'outline'} variant={ownershipFilter === f ? 'default' : 'outline'}
@ -78,7 +83,8 @@
{/each} {/each}
</div> </div>
<div class="rounded-md border border-border"> <!-- Desktop: table -->
<div class="hidden rounded-md border border-border md:block">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -128,4 +134,41 @@
</TableBody> </TableBody>
</Table> </Table>
</div> </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> </div>

View file

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

View file

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

View file

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

View file

@ -1,5 +1,8 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; 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 type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
@ -7,18 +10,132 @@
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { 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 { Separator } from '$lib/components/ui/separator';
import { SvelteMap } from 'svelte/reactivity';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
let { routine, products } = $derived(data); let { routine, products } = $derived(data);
// ── 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 showStepForm = $state(false);
let selectedProductId = $state(''); let selectedProductId = $state('');
const nextOrderIndex = $derived( const GROOMING_ACTIONS: GroomingAction[] = ['shaving_razor', 'shaving_oneblade', 'dermarolling'];
routine.steps.length ? Math.max(...routine.steps.map((s) => s.order_index)) + 1 : 0
); 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> </script>
<svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head> <svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head>
@ -43,7 +160,7 @@
<!-- Steps --> <!-- Steps -->
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <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)}> <Button variant="outline" size="sm" onclick={() => (showStepForm = !showStepForm)}>
{showStepForm ? m.common_cancel() : m["routines_addStep"]()} {showStepForm ? m.common_cancel() : m["routines_addStep"]()}
</Button> </Button>
@ -66,8 +183,13 @@
{/if} {/if}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{#each products as p (p.id)} {#each groupedProducts as [cat, items]}
<SelectItem value={p.id}>{p.name} ({p.brand})</SelectItem> <SelectGroup>
<SelectGroupHeading>{formatCategory(cat)}</SelectGroupHeading>
{#each items as p (p.id)}
<SelectItem value={p.id}>{p.name} · {p.brand}</SelectItem>
{/each}
</SelectGroup>
{/each} {/each}
</SelectContent> </SelectContent>
</Select> </Select>
@ -89,33 +211,148 @@
</Card> </Card>
{/if} {/if}
{#if routine.steps.length} {#if steps.length}
<div class="space-y-2"> <div
{#each routine.steps.toSorted((a, b) => a.order_index - b.order_index) as step (step.id)} use:dragHandleZone={{ items: steps, flipDurationMs: 200, dragDisabled: !!editingStepId || dndSaving }}
<div class="flex items-center justify-between rounded-md border border-border px-4 py-3"> onconsider={handleConsider}
<div class="flex items-center gap-3"> onfinalize={handleFinalize}
<span class="text-xs text-muted-foreground w-4">{step.order_index}</span> class="space-y-2"
<div> >
{#if step.product_id} {#each steps as step, i (step.id)}
{@const product = products.find((p) => p.id === step.product_id)} <div class="rounded-md border border-border bg-background">
<p class="text-sm font-medium">{product?.name ?? step.product_id}</p> {#if editingStepId === step.id}
{#if product?.brand}<p class="text-xs text-muted-foreground">{product.brand}</p>{/if} <!-- ── Edit mode ── -->
{:else if step.action_type} <div class="px-4 py-3 space-y-3">
<p class="text-sm font-medium">{step.action_type.replace(/_/g, ' ')}</p> {#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} {: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}
{#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> </div>
{#if step.dose} {:else}
<span class="text-xs text-muted-foreground">{step.dose}</span> <!-- ── View mode ── -->
{/if} <div class="flex items-center gap-1 px-2 py-3">
</div> <span
<form method="POST" action="?/removeStep" use:enhance> use:dragHandle
<input type="hidden" name="step_id" value={step.id} /> class="cursor-grab select-none px-1 text-muted-foreground/60 hover:text-muted-foreground"
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive"> aria-label="drag to reorder"
× >⋮⋮</span>
</Button> <span class="w-5 shrink-0 text-xs text-muted-foreground">{i + 1}.</span>
</form> <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> </div>
{/each} {/each}
</div> </div>
@ -126,8 +363,14 @@
<Separator /> <Separator />
<form method="POST" action="?/delete" use:enhance <form
onsubmit={(e) => { if (!confirm(m["routines_confirmDelete"]())) e.preventDefault(); }}> 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> <Button type="submit" variant="destructive" size="sm">{m["routines_deleteRoutine"]()}</Button>
</form> </form>
</div> </div>

View file

@ -14,13 +14,22 @@ export const actions: Actions = {
const routine_date = form.get('routine_date') as string; const routine_date = form.get('routine_date') as string;
const part_of_day = form.get('part_of_day') as 'am' | 'pm'; const part_of_day = form.get('part_of_day') as 'am' | 'pm';
const notes = (form.get('notes') as string) || undefined; 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) { if (!routine_date || !part_of_day) {
return fail(400, { error: 'Data i pora dnia są wymagane.' }); return fail(400, { error: 'Data i pora dnia są wymagane.' });
} }
try { 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 }; return { suggestion, routine_date, part_of_day };
} catch (e) { } catch (e) {
return fail(502, { error: (e as Error).message }); 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 from_date = form.get('from_date') as string;
const to_date = form.get('to_date') as string; const to_date = form.get('to_date') as string;
const notes = (form.get('notes') as string) || undefined; 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) { if (!from_date || !to_date) {
return fail(400, { error: 'Daty początkowa i końcowa są wymagane.' }); return fail(400, { error: 'Daty początkowa i końcowa są wymagane.' });
@ -44,7 +55,7 @@ export const actions: Actions = {
} }
try { 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 }; return { batch, from_date, to_date };
} catch (e) { } catch (e) {
return fail(502, { error: (e as Error).message }); return fail(502, { error: (e as Error).message });

View file

@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import { SvelteSet } from 'svelte/reactivity'; 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 type { BatchSuggestion, RoutineSuggestion, SuggestedStep } from '$lib/types';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
@ -12,7 +13,7 @@
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs'; 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]))); const productMap = $derived(Object.fromEntries(data.products.map((p) => [p.id, p])));
@ -44,7 +45,7 @@
function stepMeta(step: SuggestedStep): string { function stepMeta(step: SuggestedStep): string {
const parts: string[] = []; const parts: string[] = [];
if (step.dose) parts.push(step.dose); 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); if (step.action_notes && !step.action_type) parts.push(step.action_notes);
return parts.join(' · '); return parts.join(' · ');
} }
@ -104,7 +105,7 @@
<div class="max-w-2xl space-y-6"> <div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4"> <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> <h2 class="text-2xl font-bold tracking-tight">{m.suggest_title()}</h2>
</div> </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" 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> ></textarea>
</div> </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"> <Button type="submit" disabled={loadingSingle} class="w-full">
{#if loadingSingle} {#if loadingSingle}
@ -182,6 +209,20 @@
</CardContent> </CardContent>
</Card> </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 --> <!-- Steps -->
<div class="space-y-2"> <div class="space-y-2">
{#each suggestion.steps as step, i (i)} {#each suggestion.steps as step, i (i)}
@ -190,10 +231,18 @@
{i + 1} {i + 1}
</span> </span>
<div class="min-w-0 flex-1"> <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)} {#if stepMeta(step)}
<p class="text-xs text-muted-foreground">{stepMeta(step)}</p> <p class="text-xs text-muted-foreground">{stepMeta(step)}</p>
{/if} {/if}
{#if step.why_this_step}
<p class="text-xs text-muted-foreground italic">{step.why_this_step}</p>
{/if}
</div> </div>
</div> </div>
{/each} {/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" 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> ></textarea>
</div> </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"> <Button type="submit" disabled={loadingBatch} class="w-full">
{#if loadingBatch} {#if loadingBatch}

View file

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

View file

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

View file

@ -4,6 +4,7 @@ server {
# FastAPI backend — strip /api/ prefix # FastAPI backend — strip /api/ prefix
location /api/ { location /api/ {
client_max_body_size 16m; # up to 3 × 5 MB photos
proxy_pass http://127.0.0.1:8000/; proxy_pass http://127.0.0.1:8000/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@ -11,18 +12,6 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; 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 # SvelteKit Node server
location / { location / {
proxy_pass http://127.0.0.1:3000; proxy_pass http://127.0.0.1:3000;