Compare commits
51 commits
99584521a1
...
1d8a8eafb8
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d8a8eafb8 | |||
| 5dd8242985 | |||
| b58fcb1440 | |||
| 558708653c | |||
| cfd2485b7e | |||
| c0eeb0425d | |||
| 9bbc34ffd2 | |||
| 472a3034a0 | |||
| a7ad956a62 | |||
| 820d58ea37 | |||
| 083cd055fb | |||
| a27471a19a | |||
| 88f3642387 | |||
| 5ad9b66a21 | |||
| ba1f10d99f | |||
| 78df7322a9 | |||
| 067e460dd2 | |||
| 0e7a39836f | |||
| 28fb74b9bf | |||
| 9574c91be1 | |||
| 4627ec70bf | |||
| 30ebc093bf | |||
| 877051cfaf | |||
| 1109d9f397 | |||
| d1bdfc4993 | |||
| 098b158b75 | |||
| 609995732b | |||
| 40f9a353bb | |||
| 389ca5ffdc | |||
| 679e4e81f4 | |||
| c85ca355df | |||
| 258b8c4330 | |||
| d3bd2ff30d | |||
| f1acfa21fc | |||
| 914c6087bd | |||
| 921fe3ef61 | |||
| 49c304d06f | |||
| cc657998e8 | |||
| ada5f2a93b | |||
| 092fd87606 | |||
| 18683925a1 | |||
| 17eaa5d1bd | |||
| 75ef1bca56 | |||
| 3aa03b412b | |||
| 78c67b6179 | |||
| d4e3040674 | |||
| 5cb44b2c65 | |||
| 4b0fedde35 | |||
| 5e2536138b | |||
| 91bc9e86d7 | |||
| 3428885aaa |
70 changed files with 8306 additions and 3784 deletions
|
|
@ -1,17 +1,21 @@
|
|||
# CLAUDE.md
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
This file provides guidance to AI coding agents when working with code in this repository.
|
||||
|
||||
## Repository structure
|
||||
## Repository Structure
|
||||
|
||||
This is a monorepo. The backend lives in `backend/`; a frontend will be added in the future.
|
||||
This is a monorepo with **backend** and **frontend** directories.
|
||||
|
||||
## Commit Guidelines
|
||||
|
||||
This repository uses Conventional Commits (e.g., `feat(api): ...`, `fix(frontend): ...`, `test(models): ...`). Always format commit messages accordingly and ensure you include the correct scope to indicate which part of the monorepo is affected.
|
||||
|
||||
## Commands
|
||||
|
||||
Run all backend commands from the `backend/` directory:
|
||||
Run the backend from the `backend/` directory:
|
||||
|
||||
```bash
|
||||
# Run scripts
|
||||
# Backend
|
||||
cd backend && uv run python main.py
|
||||
|
||||
# Linting / formatting
|
||||
|
|
@ -20,11 +24,27 @@ cd backend && uv run black .
|
|||
cd backend && uv run isort .
|
||||
```
|
||||
|
||||
No test suite exists yet.
|
||||
Run the frontend from the `frontend/` directory:
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
cd frontend && pnpm dev
|
||||
|
||||
# Type checking / linting / formatting
|
||||
cd frontend && pnpm check
|
||||
cd frontend && pnpm lint
|
||||
cd frontend && pnpm format
|
||||
```
|
||||
|
||||
No test suite exists yet (backend has some test files but they're not integrated into CI).
|
||||
|
||||
## Architecture
|
||||
|
||||
**innercontext** collects personal health and skincare data and exposes it via MCP to an LLM agent. Stack: Python 3.12, SQLModel (0.0.37) + SQLAlchemy, Pydantic v2, FastAPI, PostgreSQL (psycopg3).
|
||||
**innercontext** collects personal health and skincare data and exposes it to an LLM agent.
|
||||
|
||||
**Backend Stack:** Python 3.12, SQLModel (0.0.37) + SQLAlchemy, Pydantic v2, FastAPI, PostgreSQL (psycopg3).
|
||||
|
||||
**Frontend Stack:** SvelteKit 5, Tailwind CSS v4, bits-ui, inlang/paraglide (i18n), svelte-dnd-action.
|
||||
|
||||
### Models (`backend/innercontext/models/`)
|
||||
|
||||
|
|
@ -35,7 +55,7 @@ No test suite exists yet.
|
|||
| `routine.py` | `routines`, `routine_steps` |
|
||||
| `skincare.py` | `skin_condition_snapshots` |
|
||||
|
||||
**`Product`** is the core model. JSON columns store `inci` (list), `actives` (list of `ActiveIngredient`), `recommended_for`, `targets`, `incompatible_with`, `synergizes_with`, `context_rules`, and `product_effect_profile`. The `to_llm_context()` method returns a token-optimised dict for MCP.
|
||||
**`Product`** is the core model. JSON columns store `inci` (list), `actives` (list of `ActiveIngredient`), `recommended_for`, `targets`, `incompatible_with`, `synergizes_with`, `context_rules`, and `product_effect_profile`. The `to_llm_context()` method returns a token-optimised dict for LLM usage.
|
||||
|
||||
**`ProductInventory`** tracks physical packages (opened status, expiry, remaining weight). One product → many inventory entries.
|
||||
|
||||
|
|
@ -43,7 +63,7 @@ No test suite exists yet.
|
|||
|
||||
**`SkinConditionSnapshot`** is a weekly LLM-filled record (skin state, metrics 1–5, active concerns).
|
||||
|
||||
### Key conventions
|
||||
### Key Conventions
|
||||
|
||||
- All `table=True` models use `Column(DateTime(timezone=True), onupdate=utc_now)` for `updated_at` via raw SQLAlchemy column — do not use plain `Field(default_factory=...)` for auto-update.
|
||||
- List/complex fields stored as JSON use `sa_column=Column(JSON, nullable=...)` pattern (DB-agnostic; not JSONB).
|
||||
13
README.md
13
README.md
|
|
@ -1,11 +1,11 @@
|
|||
# innercontext
|
||||
|
||||
Personal health and skincare data hub. Collects structured data (products, routines, lab results, medications, skin snapshots) and exposes it via a REST API, MCP, and a web UI to an LLM agent.
|
||||
Personal health and skincare data hub. Collects structured data (products, routines, lab results, medications, skin snapshots) and exposes it via a REST API and a web UI to an LLM agent.
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
backend/ Python backend — FastAPI REST API + MCP server + SQLModel models
|
||||
backend/ Python backend — FastAPI REST API + SQLModel models
|
||||
frontend/ SvelteKit web UI (Svelte 5, TypeScript, Tailwind CSS v4)
|
||||
docs/ Deployment guides
|
||||
nginx/ nginx config for production
|
||||
|
|
@ -60,14 +60,6 @@ UI available at `http://localhost:5173`.
|
|||
| `/skincare` | Weekly skin condition snapshots |
|
||||
| `/health-check` | Liveness probe |
|
||||
|
||||
## MCP server
|
||||
|
||||
innercontext exposes 14 tools via [FastMCP](https://github.com/jlowin/fastmcp) at the StreamableHTTP endpoint `http://localhost:8000/mcp/mcp`.
|
||||
|
||||
Tools include: `get_products`, `get_product`, `get_open_inventory`, `get_recent_routines`, `get_latest_skin_snapshot`, `get_skin_history`, `get_medications`, `get_expiring_inventory`, `get_grooming_schedule`, `get_recent_lab_results`, and more.
|
||||
|
||||
Connect an MCP-compatible LLM agent by pointing it at `http://<host>/mcp/mcp`.
|
||||
|
||||
## Frontend routes
|
||||
|
||||
| Route | Description |
|
||||
|
|
@ -102,7 +94,6 @@ uv run pytest
|
|||
## Stack
|
||||
|
||||
- **Backend:** Python 3.12, FastAPI, Uvicorn, SQLModel 0.0.37 + SQLAlchemy, Pydantic v2, PostgreSQL (psycopg3)
|
||||
- **MCP:** FastMCP 3.0.2 (StreamableHTTP transport)
|
||||
- **Frontend:** SvelteKit 2, Svelte 5 (Runes), TypeScript, Tailwind CSS v4, shadcn-svelte
|
||||
|
||||
## Deployment
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from alembic import context
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Import all models so their tables are registered in SQLModel.metadata
|
||||
import innercontext.models # noqa: F401
|
||||
import innercontext.models # noqa: F401, E402
|
||||
|
||||
config = context.config
|
||||
|
||||
|
|
|
|||
51
backend/alembic/versions/a1b2c3d4e5f6_add_ai_call_logs.py
Normal file
51
backend/alembic/versions/a1b2c3d4e5f6_add_ai_call_logs.py
Normal 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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -1,19 +1,20 @@
|
|||
"""initial_schema
|
||||
|
||||
Revision ID: c2d626a2b36c
|
||||
Revises:
|
||||
Revises:
|
||||
Create Date: 2026-02-28 20:13:55.499494
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel.sql.sqltypes
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c2d626a2b36c'
|
||||
revision: str = "c2d626a2b36c"
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
|
@ -22,217 +23,456 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('grooming_schedule',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('day_of_week', sa.Integer(), nullable=False),
|
||||
sa.Column('action', sa.Enum('SHAVING_RAZOR', 'SHAVING_ONEBLADE', 'DERMAROLLING', name='groomingaction'), nullable=False),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_table(
|
||||
"grooming_schedule",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("day_of_week", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"action",
|
||||
sa.Enum(
|
||||
"SHAVING_RAZOR",
|
||||
"SHAVING_ONEBLADE",
|
||||
"DERMAROLLING",
|
||||
name="groomingaction",
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f('ix_grooming_schedule_day_of_week'), 'grooming_schedule', ['day_of_week'], unique=False)
|
||||
op.create_table('lab_results',
|
||||
sa.Column('record_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('collected_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('test_code', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('test_name_original', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('test_name_loinc', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('value_num', sa.Float(), nullable=True),
|
||||
sa.Column('value_text', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('value_bool', sa.Boolean(), nullable=True),
|
||||
sa.Column('unit_original', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('unit_ucum', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('ref_low', sa.Float(), nullable=True),
|
||||
sa.Column('ref_high', sa.Float(), nullable=True),
|
||||
sa.Column('ref_text', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('flag', sa.Enum('NORMAL', 'ABNORMAL', 'POSITIVE', 'NEGATIVE', 'LOW', 'HIGH', name='resultflag'), nullable=True),
|
||||
sa.Column('lab', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('source_file', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('record_id')
|
||||
op.create_index(
|
||||
op.f("ix_grooming_schedule_day_of_week"),
|
||||
"grooming_schedule",
|
||||
["day_of_week"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(op.f('ix_lab_results_collected_at'), 'lab_results', ['collected_at'], unique=False)
|
||||
op.create_index(op.f('ix_lab_results_flag'), 'lab_results', ['flag'], unique=False)
|
||||
op.create_index(op.f('ix_lab_results_lab'), 'lab_results', ['lab'], unique=False)
|
||||
op.create_index(op.f('ix_lab_results_test_code'), 'lab_results', ['test_code'], unique=False)
|
||||
op.create_table('medication_entries',
|
||||
sa.Column('record_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('kind', sa.Enum('PRESCRIPTION', 'OTC', 'SUPPLEMENT', 'HERBAL', 'OTHER', name='medicationkind'), nullable=False),
|
||||
sa.Column('product_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('active_substance', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('formulation', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('route', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('source_file', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('record_id')
|
||||
op.create_table(
|
||||
"lab_results",
|
||||
sa.Column("record_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("collected_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("test_code", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column(
|
||||
"test_name_original", sqlmodel.sql.sqltypes.AutoString(), nullable=True
|
||||
),
|
||||
sa.Column("test_name_loinc", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("value_num", sa.Float(), nullable=True),
|
||||
sa.Column("value_text", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("value_bool", sa.Boolean(), nullable=True),
|
||||
sa.Column("unit_original", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("unit_ucum", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("ref_low", sa.Float(), nullable=True),
|
||||
sa.Column("ref_high", sa.Float(), nullable=True),
|
||||
sa.Column("ref_text", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column(
|
||||
"flag",
|
||||
sa.Enum(
|
||||
"NORMAL",
|
||||
"ABNORMAL",
|
||||
"POSITIVE",
|
||||
"NEGATIVE",
|
||||
"LOW",
|
||||
"HIGH",
|
||||
name="resultflag",
|
||||
),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("lab", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("source_file", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("record_id"),
|
||||
)
|
||||
op.create_index(op.f('ix_medication_entries_active_substance'), 'medication_entries', ['active_substance'], unique=False)
|
||||
op.create_index(op.f('ix_medication_entries_kind'), 'medication_entries', ['kind'], unique=False)
|
||||
op.create_index(op.f('ix_medication_entries_product_name'), 'medication_entries', ['product_name'], unique=False)
|
||||
op.create_table('products',
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('brand', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('line_name', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=True),
|
||||
sa.Column('sku', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True),
|
||||
sa.Column('url', sqlmodel.sql.sqltypes.AutoString(length=512), nullable=True),
|
||||
sa.Column('barcode', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True),
|
||||
sa.Column('category', sa.Enum('CLEANSER', 'TONER', 'ESSENCE', 'SERUM', 'MOISTURIZER', 'SPF', 'MASK', 'EXFOLIANT', 'HAIR_TREATMENT', 'TOOL', 'SPOT_TREATMENT', 'OIL', name='productcategory'), nullable=False),
|
||||
sa.Column('recommended_time', sa.Enum('AM', 'PM', 'BOTH', name='daytime'), nullable=False),
|
||||
sa.Column('texture', sa.Enum('WATERY', 'GEL', 'EMULSION', 'CREAM', 'OIL', 'BALM', 'FOAM', 'FLUID', name='texturetype'), nullable=True),
|
||||
sa.Column('absorption_speed', sa.Enum('VERY_FAST', 'FAST', 'MODERATE', 'SLOW', 'VERY_SLOW', name='absorptionspeed'), nullable=True),
|
||||
sa.Column('leave_on', sa.Boolean(), nullable=False),
|
||||
sa.Column('size_ml', sa.Float(), nullable=True),
|
||||
sa.Column('full_weight_g', sa.Float(), nullable=True),
|
||||
sa.Column('empty_weight_g', sa.Float(), nullable=True),
|
||||
sa.Column('pao_months', sa.Integer(), nullable=True),
|
||||
sa.Column('price_tier', sa.Enum('BUDGET', 'MID', 'PREMIUM', 'LUXURY', name='pricetier'), nullable=True),
|
||||
sa.Column('inci', sa.JSON(), nullable=False),
|
||||
sa.Column('actives', sa.JSON(), nullable=True),
|
||||
sa.Column('recommended_for', sa.JSON(), nullable=False),
|
||||
sa.Column('targets', sa.JSON(), nullable=False),
|
||||
sa.Column('contraindications', sa.JSON(), nullable=False),
|
||||
sa.Column('usage_notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('fragrance_free', sa.Boolean(), nullable=True),
|
||||
sa.Column('essential_oils_free', sa.Boolean(), nullable=True),
|
||||
sa.Column('alcohol_denat_free', sa.Boolean(), nullable=True),
|
||||
sa.Column('pregnancy_safe', sa.Boolean(), nullable=True),
|
||||
sa.Column('product_effect_profile', sa.JSON(), nullable=False),
|
||||
sa.Column('ph_min', sa.Float(), nullable=True),
|
||||
sa.Column('ph_max', sa.Float(), nullable=True),
|
||||
sa.Column('incompatible_with', sa.JSON(), nullable=True),
|
||||
sa.Column('synergizes_with', sa.JSON(), nullable=True),
|
||||
sa.Column('context_rules', sa.JSON(), nullable=True),
|
||||
sa.Column('min_interval_hours', sa.Integer(), nullable=True),
|
||||
sa.Column('max_frequency_per_week', sa.Integer(), nullable=True),
|
||||
sa.Column('is_medication', sa.Boolean(), nullable=False),
|
||||
sa.Column('is_tool', sa.Boolean(), nullable=False),
|
||||
sa.Column('needle_length_mm', sa.Float(), nullable=True),
|
||||
sa.Column('personal_tolerance_notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('personal_repurchase_intent', sa.Boolean(), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_index(
|
||||
op.f("ix_lab_results_collected_at"),
|
||||
"lab_results",
|
||||
["collected_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(op.f('ix_products_price_tier'), 'products', ['price_tier'], unique=False)
|
||||
op.create_table('routines',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('routine_date', sa.Date(), nullable=False),
|
||||
sa.Column('part_of_day', sa.Enum('AM', 'PM', name='partofday'), nullable=False),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('routine_date', 'part_of_day', name='uq_routine_date_part_of_day')
|
||||
op.create_index(op.f("ix_lab_results_flag"), "lab_results", ["flag"], unique=False)
|
||||
op.create_index(op.f("ix_lab_results_lab"), "lab_results", ["lab"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_lab_results_test_code"), "lab_results", ["test_code"], unique=False
|
||||
)
|
||||
op.create_index(op.f('ix_routines_part_of_day'), 'routines', ['part_of_day'], unique=False)
|
||||
op.create_index(op.f('ix_routines_routine_date'), 'routines', ['routine_date'], unique=False)
|
||||
op.create_table('skin_condition_snapshots',
|
||||
sa.Column('snapshot_date', sa.Date(), nullable=False),
|
||||
sa.Column('overall_state', sa.Enum('EXCELLENT', 'GOOD', 'FAIR', 'POOR', name='overallskinstate'), nullable=True),
|
||||
sa.Column('skin_type', sa.Enum('DRY', 'OILY', 'COMBINATION', 'SENSITIVE', 'NORMAL', 'ACNE_PRONE', name='skintype'), nullable=True),
|
||||
sa.Column('texture', sa.Enum('SMOOTH', 'ROUGH', 'FLAKY', 'BUMPY', name='skintexture'), nullable=True),
|
||||
sa.Column('hydration_level', sa.Integer(), nullable=True),
|
||||
sa.Column('sebum_tzone', sa.Integer(), nullable=True),
|
||||
sa.Column('sebum_cheeks', sa.Integer(), nullable=True),
|
||||
sa.Column('sensitivity_level', sa.Integer(), nullable=True),
|
||||
sa.Column('barrier_state', sa.Enum('INTACT', 'MILDLY_COMPROMISED', 'COMPROMISED', name='barrierstate'), nullable=True),
|
||||
sa.Column('active_concerns', sa.JSON(), nullable=False),
|
||||
sa.Column('risks', sa.JSON(), nullable=False),
|
||||
sa.Column('priorities', sa.JSON(), nullable=False),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('snapshot_date', name='uq_skin_snapshot_date')
|
||||
op.create_table(
|
||||
"medication_entries",
|
||||
sa.Column("record_id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"kind",
|
||||
sa.Enum(
|
||||
"PRESCRIPTION",
|
||||
"OTC",
|
||||
"SUPPLEMENT",
|
||||
"HERBAL",
|
||||
"OTHER",
|
||||
name="medicationkind",
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("product_name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column(
|
||||
"active_substance", sqlmodel.sql.sqltypes.AutoString(), nullable=True
|
||||
),
|
||||
sa.Column("formulation", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("route", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("source_file", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("record_id"),
|
||||
)
|
||||
op.create_index(op.f('ix_skin_condition_snapshots_snapshot_date'), 'skin_condition_snapshots', ['snapshot_date'], unique=False)
|
||||
op.create_table('medication_usages',
|
||||
sa.Column('record_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('medication_record_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('dose_value', sa.Float(), nullable=True),
|
||||
sa.Column('dose_unit', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('frequency', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('schedule_text', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('as_needed', sa.Boolean(), nullable=False),
|
||||
sa.Column('valid_from', sa.DateTime(), nullable=False),
|
||||
sa.Column('valid_to', sa.DateTime(), nullable=True),
|
||||
sa.Column('source_file', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['medication_record_id'], ['medication_entries.record_id'], ),
|
||||
sa.PrimaryKeyConstraint('record_id')
|
||||
op.create_index(
|
||||
op.f("ix_medication_entries_active_substance"),
|
||||
"medication_entries",
|
||||
["active_substance"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(op.f('ix_medication_usages_as_needed'), 'medication_usages', ['as_needed'], unique=False)
|
||||
op.create_index(op.f('ix_medication_usages_medication_record_id'), 'medication_usages', ['medication_record_id'], unique=False)
|
||||
op.create_index(op.f('ix_medication_usages_valid_from'), 'medication_usages', ['valid_from'], unique=False)
|
||||
op.create_index(op.f('ix_medication_usages_valid_to'), 'medication_usages', ['valid_to'], unique=False)
|
||||
op.create_table('product_inventory',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('product_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('is_opened', sa.Boolean(), nullable=False),
|
||||
sa.Column('opened_at', sa.Date(), nullable=True),
|
||||
sa.Column('finished_at', sa.Date(), nullable=True),
|
||||
sa.Column('expiry_date', sa.Date(), nullable=True),
|
||||
sa.Column('current_weight_g', sa.Float(), nullable=True),
|
||||
sa.Column('last_weighed_at', sa.Date(), nullable=True),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_index(
|
||||
op.f("ix_medication_entries_kind"), "medication_entries", ["kind"], unique=False
|
||||
)
|
||||
op.create_index(op.f('ix_product_inventory_product_id'), 'product_inventory', ['product_id'], unique=False)
|
||||
op.create_table('routine_steps',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('routine_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('product_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('order_index', sa.Integer(), nullable=False),
|
||||
sa.Column('action_type', sa.Enum('SHAVING_RAZOR', 'SHAVING_ONEBLADE', 'DERMAROLLING', name='groomingaction'), nullable=True),
|
||||
sa.Column('action_notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('dose', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('region', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
|
||||
sa.ForeignKeyConstraint(['routine_id'], ['routines.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_index(
|
||||
op.f("ix_medication_entries_product_name"),
|
||||
"medication_entries",
|
||||
["product_name"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_table(
|
||||
"products",
|
||||
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("brand", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column(
|
||||
"line_name", sqlmodel.sql.sqltypes.AutoString(length=128), nullable=True
|
||||
),
|
||||
sa.Column("sku", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True),
|
||||
sa.Column("url", sqlmodel.sql.sqltypes.AutoString(length=512), nullable=True),
|
||||
sa.Column(
|
||||
"barcode", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True
|
||||
),
|
||||
sa.Column(
|
||||
"category",
|
||||
sa.Enum(
|
||||
"CLEANSER",
|
||||
"TONER",
|
||||
"ESSENCE",
|
||||
"SERUM",
|
||||
"MOISTURIZER",
|
||||
"SPF",
|
||||
"MASK",
|
||||
"EXFOLIANT",
|
||||
"HAIR_TREATMENT",
|
||||
"TOOL",
|
||||
"SPOT_TREATMENT",
|
||||
"OIL",
|
||||
name="productcategory",
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"recommended_time",
|
||||
sa.Enum("AM", "PM", "BOTH", name="daytime"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"texture",
|
||||
sa.Enum(
|
||||
"WATERY",
|
||||
"GEL",
|
||||
"EMULSION",
|
||||
"CREAM",
|
||||
"OIL",
|
||||
"BALM",
|
||||
"FOAM",
|
||||
"FLUID",
|
||||
name="texturetype",
|
||||
),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"absorption_speed",
|
||||
sa.Enum(
|
||||
"VERY_FAST",
|
||||
"FAST",
|
||||
"MODERATE",
|
||||
"SLOW",
|
||||
"VERY_SLOW",
|
||||
name="absorptionspeed",
|
||||
),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("leave_on", sa.Boolean(), nullable=False),
|
||||
sa.Column("size_ml", sa.Float(), nullable=True),
|
||||
sa.Column("full_weight_g", sa.Float(), nullable=True),
|
||||
sa.Column("empty_weight_g", sa.Float(), nullable=True),
|
||||
sa.Column("pao_months", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"price_tier",
|
||||
sa.Enum("BUDGET", "MID", "PREMIUM", "LUXURY", name="pricetier"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("inci", sa.JSON(), nullable=False),
|
||||
sa.Column("actives", sa.JSON(), nullable=True),
|
||||
sa.Column("recommended_for", sa.JSON(), nullable=False),
|
||||
sa.Column("targets", sa.JSON(), nullable=False),
|
||||
sa.Column("contraindications", sa.JSON(), nullable=False),
|
||||
sa.Column("usage_notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("fragrance_free", sa.Boolean(), nullable=True),
|
||||
sa.Column("essential_oils_free", sa.Boolean(), nullable=True),
|
||||
sa.Column("alcohol_denat_free", sa.Boolean(), nullable=True),
|
||||
sa.Column("pregnancy_safe", sa.Boolean(), nullable=True),
|
||||
sa.Column("product_effect_profile", sa.JSON(), nullable=False),
|
||||
sa.Column("ph_min", sa.Float(), nullable=True),
|
||||
sa.Column("ph_max", sa.Float(), nullable=True),
|
||||
sa.Column("incompatible_with", sa.JSON(), nullable=True),
|
||||
sa.Column("synergizes_with", sa.JSON(), nullable=True),
|
||||
sa.Column("context_rules", sa.JSON(), nullable=True),
|
||||
sa.Column("min_interval_hours", sa.Integer(), nullable=True),
|
||||
sa.Column("max_frequency_per_week", sa.Integer(), nullable=True),
|
||||
sa.Column("is_medication", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_tool", sa.Boolean(), nullable=False),
|
||||
sa.Column("needle_length_mm", sa.Float(), nullable=True),
|
||||
sa.Column(
|
||||
"personal_tolerance_notes",
|
||||
sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("personal_repurchase_intent", sa.Boolean(), nullable=True),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_products_price_tier"), "products", ["price_tier"], unique=False
|
||||
)
|
||||
op.create_table(
|
||||
"routines",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("routine_date", sa.Date(), nullable=False),
|
||||
sa.Column("part_of_day", sa.Enum("AM", "PM", name="partofday"), nullable=False),
|
||||
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"routine_date", "part_of_day", name="uq_routine_date_part_of_day"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_routines_part_of_day"), "routines", ["part_of_day"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_routines_routine_date"), "routines", ["routine_date"], unique=False
|
||||
)
|
||||
op.create_table(
|
||||
"skin_condition_snapshots",
|
||||
sa.Column("snapshot_date", sa.Date(), nullable=False),
|
||||
sa.Column(
|
||||
"overall_state",
|
||||
sa.Enum("EXCELLENT", "GOOD", "FAIR", "POOR", name="overallskinstate"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"skin_type",
|
||||
sa.Enum(
|
||||
"DRY",
|
||||
"OILY",
|
||||
"COMBINATION",
|
||||
"SENSITIVE",
|
||||
"NORMAL",
|
||||
"ACNE_PRONE",
|
||||
name="skintype",
|
||||
),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"texture",
|
||||
sa.Enum("SMOOTH", "ROUGH", "FLAKY", "BUMPY", name="skintexture"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("hydration_level", sa.Integer(), nullable=True),
|
||||
sa.Column("sebum_tzone", sa.Integer(), nullable=True),
|
||||
sa.Column("sebum_cheeks", sa.Integer(), nullable=True),
|
||||
sa.Column("sensitivity_level", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"barrier_state",
|
||||
sa.Enum("INTACT", "MILDLY_COMPROMISED", "COMPROMISED", name="barrierstate"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("active_concerns", sa.JSON(), nullable=False),
|
||||
sa.Column("risks", sa.JSON(), nullable=False),
|
||||
sa.Column("priorities", sa.JSON(), nullable=False),
|
||||
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("snapshot_date", name="uq_skin_snapshot_date"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_skin_condition_snapshots_snapshot_date"),
|
||||
"skin_condition_snapshots",
|
||||
["snapshot_date"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_table(
|
||||
"medication_usages",
|
||||
sa.Column("record_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("medication_record_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("dose_value", sa.Float(), nullable=True),
|
||||
sa.Column("dose_unit", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("frequency", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("schedule_text", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("as_needed", sa.Boolean(), nullable=False),
|
||||
sa.Column("valid_from", sa.DateTime(), nullable=False),
|
||||
sa.Column("valid_to", sa.DateTime(), nullable=True),
|
||||
sa.Column("source_file", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["medication_record_id"],
|
||||
["medication_entries.record_id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("record_id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_medication_usages_as_needed"),
|
||||
"medication_usages",
|
||||
["as_needed"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_medication_usages_medication_record_id"),
|
||||
"medication_usages",
|
||||
["medication_record_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_medication_usages_valid_from"),
|
||||
"medication_usages",
|
||||
["valid_from"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_medication_usages_valid_to"),
|
||||
"medication_usages",
|
||||
["valid_to"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_table(
|
||||
"product_inventory",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("product_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("is_opened", sa.Boolean(), nullable=False),
|
||||
sa.Column("opened_at", sa.Date(), nullable=True),
|
||||
sa.Column("finished_at", sa.Date(), nullable=True),
|
||||
sa.Column("expiry_date", sa.Date(), nullable=True),
|
||||
sa.Column("current_weight_g", sa.Float(), nullable=True),
|
||||
sa.Column("last_weighed_at", sa.Date(), nullable=True),
|
||||
sa.Column("notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["product_id"],
|
||||
["products.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_product_inventory_product_id"),
|
||||
"product_inventory",
|
||||
["product_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_table(
|
||||
"routine_steps",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("routine_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("product_id", sa.Uuid(), nullable=True),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"action_type",
|
||||
sa.Enum(
|
||||
"SHAVING_RAZOR",
|
||||
"SHAVING_ONEBLADE",
|
||||
"DERMAROLLING",
|
||||
name="groomingaction",
|
||||
),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("action_notes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("dose", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("region", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["product_id"],
|
||||
["products.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["routine_id"],
|
||||
["routines.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_routine_steps_product_id"),
|
||||
"routine_steps",
|
||||
["product_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_routine_steps_routine_id"),
|
||||
"routine_steps",
|
||||
["routine_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(op.f('ix_routine_steps_product_id'), 'routine_steps', ['product_id'], unique=False)
|
||||
op.create_index(op.f('ix_routine_steps_routine_id'), 'routine_steps', ['routine_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_routine_steps_routine_id'), table_name='routine_steps')
|
||||
op.drop_index(op.f('ix_routine_steps_product_id'), table_name='routine_steps')
|
||||
op.drop_table('routine_steps')
|
||||
op.drop_index(op.f('ix_product_inventory_product_id'), table_name='product_inventory')
|
||||
op.drop_table('product_inventory')
|
||||
op.drop_index(op.f('ix_medication_usages_valid_to'), table_name='medication_usages')
|
||||
op.drop_index(op.f('ix_medication_usages_valid_from'), table_name='medication_usages')
|
||||
op.drop_index(op.f('ix_medication_usages_medication_record_id'), table_name='medication_usages')
|
||||
op.drop_index(op.f('ix_medication_usages_as_needed'), table_name='medication_usages')
|
||||
op.drop_table('medication_usages')
|
||||
op.drop_index(op.f('ix_skin_condition_snapshots_snapshot_date'), table_name='skin_condition_snapshots')
|
||||
op.drop_table('skin_condition_snapshots')
|
||||
op.drop_index(op.f('ix_routines_routine_date'), table_name='routines')
|
||||
op.drop_index(op.f('ix_routines_part_of_day'), table_name='routines')
|
||||
op.drop_table('routines')
|
||||
op.drop_index(op.f('ix_products_price_tier'), table_name='products')
|
||||
op.drop_table('products')
|
||||
op.drop_index(op.f('ix_medication_entries_product_name'), table_name='medication_entries')
|
||||
op.drop_index(op.f('ix_medication_entries_kind'), table_name='medication_entries')
|
||||
op.drop_index(op.f('ix_medication_entries_active_substance'), table_name='medication_entries')
|
||||
op.drop_table('medication_entries')
|
||||
op.drop_index(op.f('ix_lab_results_test_code'), table_name='lab_results')
|
||||
op.drop_index(op.f('ix_lab_results_lab'), table_name='lab_results')
|
||||
op.drop_index(op.f('ix_lab_results_flag'), table_name='lab_results')
|
||||
op.drop_index(op.f('ix_lab_results_collected_at'), table_name='lab_results')
|
||||
op.drop_table('lab_results')
|
||||
op.drop_index(op.f('ix_grooming_schedule_day_of_week'), table_name='grooming_schedule')
|
||||
op.drop_table('grooming_schedule')
|
||||
op.drop_index(op.f("ix_routine_steps_routine_id"), table_name="routine_steps")
|
||||
op.drop_index(op.f("ix_routine_steps_product_id"), table_name="routine_steps")
|
||||
op.drop_table("routine_steps")
|
||||
op.drop_index(
|
||||
op.f("ix_product_inventory_product_id"), table_name="product_inventory"
|
||||
)
|
||||
op.drop_table("product_inventory")
|
||||
op.drop_index(op.f("ix_medication_usages_valid_to"), table_name="medication_usages")
|
||||
op.drop_index(
|
||||
op.f("ix_medication_usages_valid_from"), table_name="medication_usages"
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_medication_usages_medication_record_id"),
|
||||
table_name="medication_usages",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_medication_usages_as_needed"), table_name="medication_usages"
|
||||
)
|
||||
op.drop_table("medication_usages")
|
||||
op.drop_index(
|
||||
op.f("ix_skin_condition_snapshots_snapshot_date"),
|
||||
table_name="skin_condition_snapshots",
|
||||
)
|
||||
op.drop_table("skin_condition_snapshots")
|
||||
op.drop_index(op.f("ix_routines_routine_date"), table_name="routines")
|
||||
op.drop_index(op.f("ix_routines_part_of_day"), table_name="routines")
|
||||
op.drop_table("routines")
|
||||
op.drop_index(op.f("ix_products_price_tier"), table_name="products")
|
||||
op.drop_table("products")
|
||||
op.drop_index(
|
||||
op.f("ix_medication_entries_product_name"), table_name="medication_entries"
|
||||
)
|
||||
op.drop_index(op.f("ix_medication_entries_kind"), table_name="medication_entries")
|
||||
op.drop_index(
|
||||
op.f("ix_medication_entries_active_substance"), table_name="medication_entries"
|
||||
)
|
||||
op.drop_table("medication_entries")
|
||||
op.drop_index(op.f("ix_lab_results_test_code"), table_name="lab_results")
|
||||
op.drop_index(op.f("ix_lab_results_lab"), table_name="lab_results")
|
||||
op.drop_index(op.f("ix_lab_results_flag"), table_name="lab_results")
|
||||
op.drop_index(op.f("ix_lab_results_collected_at"), table_name="lab_results")
|
||||
op.drop_table("lab_results")
|
||||
op.drop_index(
|
||||
op.f("ix_grooming_schedule_day_of_week"), table_name="grooming_schedule"
|
||||
)
|
||||
op.drop_table("grooming_schedule")
|
||||
# ### end Alembic commands ###
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
83
backend/innercontext/api/ai_logs.py
Normal file
83
backend/innercontext/api/ai_logs.py
Normal 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
|
||||
|
|
@ -5,12 +5,18 @@ from uuid import UUID, uuid4
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from google.genai import types as genai_types
|
||||
from pydantic import BaseModel as PydanticBase
|
||||
from pydantic import ValidationError
|
||||
from sqlmodel import Session, SQLModel, select
|
||||
from sqlmodel import Session, SQLModel, col, select
|
||||
|
||||
from db import get_session
|
||||
from innercontext.api.utils import get_or_404
|
||||
from innercontext.llm import get_gemini_client
|
||||
from innercontext.llm import (
|
||||
call_gemini,
|
||||
call_gemini_with_function_tools,
|
||||
get_creative_config,
|
||||
get_extraction_config,
|
||||
)
|
||||
from innercontext.models import (
|
||||
Product,
|
||||
ProductBase,
|
||||
|
|
@ -19,6 +25,7 @@ from innercontext.models import (
|
|||
ProductPublic,
|
||||
ProductWithInventory,
|
||||
SkinConcern,
|
||||
SkinConditionSnapshot,
|
||||
)
|
||||
from innercontext.models.enums import (
|
||||
AbsorptionSpeed,
|
||||
|
|
@ -143,6 +150,19 @@ class ProductParseResponse(SQLModel):
|
|||
needle_length_mm: Optional[float] = None
|
||||
|
||||
|
||||
class AIActiveIngredient(ActiveIngredient):
|
||||
# Gemini API rejects int-enum values in response_schema; override with plain int.
|
||||
strength_level: Optional[int] = None # type: ignore[assignment]
|
||||
irritation_potential: Optional[int] = None # type: ignore[assignment]
|
||||
|
||||
|
||||
class ProductParseLLMResponse(ProductParseResponse):
|
||||
# Gemini response schema currently requires enum values to be strings.
|
||||
# Strength fields are numeric in our domain (1-3), so keep them as ints here
|
||||
# and convert via ProductParseResponse validation afterward.
|
||||
actives: Optional[list[AIActiveIngredient]] = None # type: ignore[assignment]
|
||||
|
||||
|
||||
class InventoryCreate(SQLModel):
|
||||
is_opened: bool = False
|
||||
opened_at: Optional[date] = None
|
||||
|
|
@ -163,6 +183,41 @@ class InventoryUpdate(SQLModel):
|
|||
notes: Optional[str] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shopping suggestion schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProductSuggestion(PydanticBase):
|
||||
category: str
|
||||
product_type: str
|
||||
key_ingredients: list[str]
|
||||
target_concerns: list[str]
|
||||
why_needed: str
|
||||
recommended_time: str
|
||||
frequency: str
|
||||
|
||||
|
||||
class ShoppingSuggestionResponse(PydanticBase):
|
||||
suggestions: list[ProductSuggestion]
|
||||
reasoning: str
|
||||
|
||||
|
||||
class _ProductSuggestionOut(PydanticBase):
|
||||
category: str
|
||||
product_type: str
|
||||
key_ingredients: list[str]
|
||||
target_concerns: list[str]
|
||||
why_needed: str
|
||||
recommended_time: str
|
||||
frequency: str
|
||||
|
||||
|
||||
class _ShoppingSuggestionsOut(PydanticBase):
|
||||
suggestions: list[_ProductSuggestionOut]
|
||||
reasoning: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Product routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -205,7 +260,9 @@ def list_products(
|
|||
product_ids = [p.id for p in products]
|
||||
inventory_rows = (
|
||||
session.exec(
|
||||
select(ProductInventory).where(ProductInventory.product_id.in_(product_ids))
|
||||
select(ProductInventory).where(
|
||||
col(ProductInventory.product_id).in_(product_ids)
|
||||
)
|
||||
).all()
|
||||
if product_ids
|
||||
else []
|
||||
|
|
@ -367,17 +424,15 @@ OUTPUT SCHEMA (all fields optional — omit what you cannot determine):
|
|||
|
||||
@router.post("/parse-text", response_model=ProductParseResponse)
|
||||
def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
|
||||
client, model = get_gemini_client()
|
||||
response = client.models.generate_content(
|
||||
model=model,
|
||||
response = call_gemini(
|
||||
endpoint="products/parse-text",
|
||||
contents=f"Extract product data from this text:\n\n{data.text}",
|
||||
config=genai_types.GenerateContentConfig(
|
||||
config=get_extraction_config(
|
||||
system_instruction=_product_parse_system_prompt(),
|
||||
response_mime_type="application/json",
|
||||
response_schema=ProductParseResponse,
|
||||
response_schema=ProductParseLLMResponse,
|
||||
max_output_tokens=16384,
|
||||
temperature=0.0,
|
||||
),
|
||||
user_input=data.text,
|
||||
)
|
||||
raw = response.text
|
||||
if not raw:
|
||||
|
|
@ -387,7 +442,8 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
|
|||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
|
||||
try:
|
||||
return ProductParseResponse.model_validate(parsed)
|
||||
llm_parsed = ProductParseLLMResponse.model_validate(parsed)
|
||||
return ProductParseResponse.model_validate(llm_parsed.model_dump())
|
||||
except ValidationError as e:
|
||||
raise HTTPException(status_code=422, detail=e.errors())
|
||||
|
||||
|
|
@ -453,3 +509,425 @@ def create_product_inventory(
|
|||
session.commit()
|
||||
session.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shopping suggestion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ev(v: object) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
value = getattr(v, "value", None)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return str(v)
|
||||
|
||||
|
||||
def _build_shopping_context(session: Session) -> str:
|
||||
snapshot = session.exec(
|
||||
select(SkinConditionSnapshot).order_by(
|
||||
col(SkinConditionSnapshot.snapshot_date).desc()
|
||||
)
|
||||
).first()
|
||||
|
||||
skin_lines = ["STAN SKÓRY:"]
|
||||
if snapshot:
|
||||
skin_lines.append(f" Data: {snapshot.snapshot_date}")
|
||||
skin_lines.append(f" Ogólny stan: {_ev(snapshot.overall_state)}")
|
||||
skin_lines.append(f" Typ skóry: {_ev(snapshot.skin_type)}")
|
||||
skin_lines.append(f" Nawilżenie: {snapshot.hydration_level}/5")
|
||||
skin_lines.append(f" Wrażliwość: {snapshot.sensitivity_level}/5")
|
||||
skin_lines.append(f" Bariera: {_ev(snapshot.barrier_state)}")
|
||||
concerns = ", ".join(_ev(c) for c in (snapshot.active_concerns or []))
|
||||
skin_lines.append(f" Aktywne problemy: {concerns or 'brak'}")
|
||||
if snapshot.priorities:
|
||||
skin_lines.append(f" Priorytety: {', '.join(snapshot.priorities)}")
|
||||
else:
|
||||
skin_lines.append(" (brak danych)")
|
||||
|
||||
products = _get_shopping_products(session)
|
||||
|
||||
product_ids = [p.id for p in products]
|
||||
inventory_rows = (
|
||||
session.exec(
|
||||
select(ProductInventory).where(
|
||||
col(ProductInventory.product_id).in_(product_ids)
|
||||
)
|
||||
).all()
|
||||
if product_ids
|
||||
else []
|
||||
)
|
||||
inv_by_product: dict = {}
|
||||
for inv in inventory_rows:
|
||||
inv_by_product.setdefault(inv.product_id, []).append(inv)
|
||||
|
||||
products_lines = ["POSIADANE PRODUKTY:"]
|
||||
products_lines.append(
|
||||
" Legenda: [✓] = produkt dostępny (w magazynie), [✗] = brak w magazynie"
|
||||
)
|
||||
for p in products:
|
||||
active_inv = [i for i in inv_by_product.get(p.id, []) if i.finished_at is None]
|
||||
has_stock = len(active_inv) > 0 # any unfinished inventory = in stock
|
||||
stock = "✓" if has_stock else "✗"
|
||||
|
||||
actives = _extract_active_names(p)
|
||||
actives_str = f", actives: {actives}" if actives else ""
|
||||
|
||||
ep = p.product_effect_profile
|
||||
if isinstance(ep, dict):
|
||||
effects = {k.replace("_strength", ""): v for k, v in ep.items() if v >= 3}
|
||||
else:
|
||||
effects = {
|
||||
k.replace("_strength", ""): v
|
||||
for k, v in ep.model_dump().items()
|
||||
if v >= 3
|
||||
}
|
||||
effects_str = f", effects: {effects}" if effects else ""
|
||||
|
||||
targets = [_ev(t) for t in (p.targets or [])]
|
||||
|
||||
products_lines.append(
|
||||
f" [{stock}] id={p.id} {p.name} ({p.brand or ''}) - {_ev(p.category)}, "
|
||||
f"targets: {targets}{actives_str}{effects_str}"
|
||||
)
|
||||
|
||||
return "\n".join(skin_lines) + "\n\n" + "\n".join(products_lines)
|
||||
|
||||
|
||||
def _get_shopping_products(session: Session) -> list[Product]:
|
||||
stmt = select(Product).where(col(Product.is_tool).is_(False))
|
||||
products = session.exec(stmt).all()
|
||||
return [p for p in products if not p.is_medication]
|
||||
|
||||
|
||||
def _extract_active_names(product: Product) -> list[str]:
|
||||
names: list[str] = []
|
||||
for active in product.actives or []:
|
||||
if isinstance(active, dict):
|
||||
name = str(active.get("name") or "").strip()
|
||||
else:
|
||||
name = str(getattr(active, "name", "") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
if name in names:
|
||||
continue
|
||||
names.append(name)
|
||||
if len(names) >= 12:
|
||||
break
|
||||
return names
|
||||
|
||||
|
||||
def _extract_requested_product_ids(
|
||||
args: dict[str, object], max_ids: int = 8
|
||||
) -> list[str]:
|
||||
raw_ids = args.get("product_ids")
|
||||
if not isinstance(raw_ids, list):
|
||||
return []
|
||||
|
||||
requested_ids: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw_id in raw_ids:
|
||||
if not isinstance(raw_id, str):
|
||||
continue
|
||||
if raw_id in seen:
|
||||
continue
|
||||
seen.add(raw_id)
|
||||
requested_ids.append(raw_id)
|
||||
if len(requested_ids) >= max_ids:
|
||||
break
|
||||
return requested_ids
|
||||
|
||||
|
||||
def _build_product_details_tool_handler(products: list[Product], mapper):
|
||||
available_by_id = {str(p.id): p for p in products}
|
||||
|
||||
def _handler(args: dict[str, object]) -> dict[str, object]:
|
||||
requested_ids = _extract_requested_product_ids(args)
|
||||
products_payload = []
|
||||
for pid in requested_ids:
|
||||
product = available_by_id.get(pid)
|
||||
if product is None:
|
||||
continue
|
||||
products_payload.append(mapper(product, pid))
|
||||
return {"products": products_payload}
|
||||
|
||||
return _handler
|
||||
|
||||
|
||||
def _build_inci_tool_handler(products: list[Product]):
|
||||
def _mapper(product: Product, pid: str) -> dict[str, object]:
|
||||
inci = product.inci or []
|
||||
compact_inci = [str(i)[:120] for i in inci[:128]]
|
||||
return {"id": pid, "name": product.name, "inci": compact_inci}
|
||||
|
||||
return _build_product_details_tool_handler(products, mapper=_mapper)
|
||||
|
||||
|
||||
def _build_actives_tool_handler(products: list[Product]):
|
||||
def _mapper(product: Product, pid: str) -> dict[str, object]:
|
||||
payload = []
|
||||
for active in product.actives or []:
|
||||
if isinstance(active, dict):
|
||||
name = str(active.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
item = {"name": name}
|
||||
percent = active.get("percent")
|
||||
if percent is not None:
|
||||
item["percent"] = percent
|
||||
functions = active.get("functions")
|
||||
if isinstance(functions, list):
|
||||
item["functions"] = [str(f) for f in functions[:4]]
|
||||
strength_level = active.get("strength_level")
|
||||
if strength_level is not None:
|
||||
item["strength_level"] = str(strength_level)
|
||||
payload.append(item)
|
||||
continue
|
||||
|
||||
name = str(getattr(active, "name", "") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
item = {"name": name}
|
||||
percent = getattr(active, "percent", None)
|
||||
if percent is not None:
|
||||
item["percent"] = percent
|
||||
functions = getattr(active, "functions", None)
|
||||
if isinstance(functions, list):
|
||||
item["functions"] = [_ev(f) for f in functions[:4]]
|
||||
strength_level = getattr(active, "strength_level", None)
|
||||
if strength_level is not None:
|
||||
item["strength_level"] = _ev(strength_level)
|
||||
payload.append(item)
|
||||
return {"id": pid, "name": product.name, "actives": payload[:24]}
|
||||
|
||||
return _build_product_details_tool_handler(products, mapper=_mapper)
|
||||
|
||||
|
||||
def _build_usage_notes_tool_handler(products: list[Product]):
|
||||
def _mapper(product: Product, pid: str) -> dict[str, object]:
|
||||
notes = " ".join(str(product.usage_notes or "").split())
|
||||
if len(notes) > 500:
|
||||
notes = notes[:497] + "..."
|
||||
return {"id": pid, "name": product.name, "usage_notes": notes}
|
||||
|
||||
return _build_product_details_tool_handler(products, mapper=_mapper)
|
||||
|
||||
|
||||
def _build_safety_rules_tool_handler(products: list[Product]):
|
||||
def _mapper(product: Product, pid: str) -> dict[str, object]:
|
||||
ctx = product.to_llm_context()
|
||||
return {
|
||||
"id": pid,
|
||||
"name": product.name,
|
||||
"incompatible_with": (ctx.get("incompatible_with") or [])[:24],
|
||||
"contraindications": (ctx.get("contraindications") or [])[:24],
|
||||
"context_rules": ctx.get("context_rules") or {},
|
||||
"safety": ctx.get("safety") or {},
|
||||
"min_interval_hours": ctx.get("min_interval_hours"),
|
||||
"max_frequency_per_week": ctx.get("max_frequency_per_week"),
|
||||
}
|
||||
|
||||
return _build_product_details_tool_handler(products, mapper=_mapper)
|
||||
|
||||
|
||||
_INCI_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
|
||||
name="get_product_inci",
|
||||
description=(
|
||||
"Return exact INCI ingredient lists for selected product UUIDs from "
|
||||
"POSIADANE PRODUKTY."
|
||||
),
|
||||
parameters=genai_types.Schema(
|
||||
type=genai_types.Type.OBJECT,
|
||||
properties={
|
||||
"product_ids": genai_types.Schema(
|
||||
type=genai_types.Type.ARRAY,
|
||||
items=genai_types.Schema(type=genai_types.Type.STRING),
|
||||
description="Product UUIDs from POSIADANE PRODUKTY.",
|
||||
)
|
||||
},
|
||||
required=["product_ids"],
|
||||
),
|
||||
)
|
||||
|
||||
_SAFETY_RULES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
|
||||
name="get_product_safety_rules",
|
||||
description=(
|
||||
"Return safety and compatibility rules for selected product UUIDs, "
|
||||
"including incompatible_with, contraindications, context_rules and safety flags."
|
||||
),
|
||||
parameters=genai_types.Schema(
|
||||
type=genai_types.Type.OBJECT,
|
||||
properties={
|
||||
"product_ids": genai_types.Schema(
|
||||
type=genai_types.Type.ARRAY,
|
||||
items=genai_types.Schema(type=genai_types.Type.STRING),
|
||||
description="Product UUIDs from POSIADANE PRODUKTY.",
|
||||
)
|
||||
},
|
||||
required=["product_ids"],
|
||||
),
|
||||
)
|
||||
|
||||
_ACTIVES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
|
||||
name="get_product_actives",
|
||||
description=(
|
||||
"Return detailed active ingredients for selected product UUIDs, "
|
||||
"including concentration and functions when available."
|
||||
),
|
||||
parameters=genai_types.Schema(
|
||||
type=genai_types.Type.OBJECT,
|
||||
properties={
|
||||
"product_ids": genai_types.Schema(
|
||||
type=genai_types.Type.ARRAY,
|
||||
items=genai_types.Schema(type=genai_types.Type.STRING),
|
||||
description="Product UUIDs from POSIADANE PRODUKTY.",
|
||||
)
|
||||
},
|
||||
required=["product_ids"],
|
||||
),
|
||||
)
|
||||
|
||||
_USAGE_NOTES_FUNCTION_DECLARATION = genai_types.FunctionDeclaration(
|
||||
name="get_product_usage_notes",
|
||||
description=(
|
||||
"Return compact usage notes for selected product UUIDs "
|
||||
"(timing, application method and cautions)."
|
||||
),
|
||||
parameters=genai_types.Schema(
|
||||
type=genai_types.Type.OBJECT,
|
||||
properties={
|
||||
"product_ids": genai_types.Schema(
|
||||
type=genai_types.Type.ARRAY,
|
||||
items=genai_types.Schema(type=genai_types.Type.STRING),
|
||||
description="Product UUIDs from POSIADANE PRODUKTY.",
|
||||
)
|
||||
},
|
||||
required=["product_ids"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_SHOPPING_SYSTEM_PROMPT = """Jesteś asystentem zakupowym w dziedzinie pielęgnacji skóry.
|
||||
Twoim zadaniem jest przeanalizować stan skóry użytkownika oraz produkty, które już posiada,
|
||||
a następnie zasugerować TYPY produktów (bez marek), które mogłyby uzupełnić ich rutynę.
|
||||
|
||||
LEGENDA:
|
||||
- [✓] = produkt dostępny w magazynie (nawet jeśli jest zapieczętowany)
|
||||
- [✗] = produkt niedostępny (brak w magazynie, wszystkie opakowania zużyte)
|
||||
|
||||
ZASADY:
|
||||
0. Sugeruj tylko wtedy, gdy jest realna potrzeba - nie zwracaj stałej liczby produktów
|
||||
1. Sugeruj TYLKO typy produktów, NIGDY konkretne marki (np. "Salicylic Acid 2% Masque", nie "La Roche-Posay")
|
||||
2. Produkty oznaczone [✗] to te, których NIE MA w magazynie - możesz je zasugerować
|
||||
3. Produkty oznaczone [✓] są 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
|
|
@ -11,7 +11,7 @@ from sqlmodel import Session, SQLModel, select
|
|||
|
||||
from db import get_session
|
||||
from innercontext.api.utils import get_or_404
|
||||
from innercontext.llm import get_gemini_client
|
||||
from innercontext.llm import call_gemini, get_extraction_config
|
||||
from innercontext.models import (
|
||||
SkinConditionSnapshot,
|
||||
SkinConditionSnapshotBase,
|
||||
|
|
@ -140,37 +140,44 @@ async def analyze_skin_photos(
|
|||
if not (1 <= len(photos) <= 3):
|
||||
raise HTTPException(status_code=422, detail="Send between 1 and 3 photos.")
|
||||
|
||||
client, model = get_gemini_client()
|
||||
|
||||
allowed = {"image/jpeg", "image/png", "image/webp"}
|
||||
allowed = {
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
}
|
||||
parts: list[genai_types.Part] = []
|
||||
for photo in photos:
|
||||
if photo.content_type not in allowed:
|
||||
raise HTTPException(status_code=422, detail=f"Unsupported type: {photo.content_type}")
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"Unsupported type: {photo.content_type}"
|
||||
)
|
||||
data = await photo.read()
|
||||
if len(data) > MAX_IMAGE_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"{photo.filename} exceeds 5 MB.")
|
||||
parts.append(genai_types.Part.from_bytes(data=data, mime_type=photo.content_type))
|
||||
raise HTTPException(
|
||||
status_code=413, detail=f"{photo.filename} exceeds 5 MB."
|
||||
)
|
||||
parts.append(
|
||||
genai_types.Part.from_bytes(data=data, mime_type=photo.content_type)
|
||||
)
|
||||
parts.append(
|
||||
genai_types.Part.from_text(
|
||||
text="Analyze the skin condition visible in the above photo(s) and return the JSON assessment."
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model=model,
|
||||
contents=parts,
|
||||
config=genai_types.GenerateContentConfig(
|
||||
system_instruction=_skin_photo_system_prompt(),
|
||||
response_mime_type="application/json",
|
||||
response_schema=_SkinAnalysisOut,
|
||||
max_output_tokens=2048,
|
||||
temperature=0.0,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Gemini API error: {e}")
|
||||
image_summary = f"{len(photos)} image(s): {', '.join((p.content_type or 'unknown') for p in photos)}"
|
||||
response = call_gemini(
|
||||
endpoint="skincare/analyze-photos",
|
||||
contents=parts,
|
||||
config=get_extraction_config(
|
||||
system_instruction=_skin_photo_system_prompt(),
|
||||
response_schema=_SkinAnalysisOut,
|
||||
max_output_tokens=2048,
|
||||
),
|
||||
user_input=image_summary,
|
||||
)
|
||||
|
||||
try:
|
||||
parsed = json.loads(response.text)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,53 @@
|
|||
"""Shared helpers for Gemini API access."""
|
||||
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from google import genai
|
||||
from google.genai import types as genai_types
|
||||
|
||||
_DEFAULT_MODEL = "gemini-3-flash-preview"
|
||||
|
||||
|
||||
_DEFAULT_MODEL = "gemini-flash-latest"
|
||||
def get_extraction_config(
|
||||
system_instruction: str,
|
||||
response_schema: Any,
|
||||
max_output_tokens: int = 8192,
|
||||
) -> genai_types.GenerateContentConfig:
|
||||
"""Config for strict data extraction (deterministic, minimal thinking)."""
|
||||
return genai_types.GenerateContentConfig(
|
||||
system_instruction=system_instruction,
|
||||
response_mime_type="application/json",
|
||||
response_schema=response_schema,
|
||||
max_output_tokens=max_output_tokens,
|
||||
temperature=0.0,
|
||||
thinking_config=genai_types.ThinkingConfig(
|
||||
thinking_level=genai_types.ThinkingLevel.MINIMAL
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_creative_config(
|
||||
system_instruction: str,
|
||||
response_schema: Any,
|
||||
max_output_tokens: int = 4096,
|
||||
) -> genai_types.GenerateContentConfig:
|
||||
"""Config for creative tasks like recommendations (balanced creativity)."""
|
||||
return genai_types.GenerateContentConfig(
|
||||
system_instruction=system_instruction,
|
||||
response_mime_type="application/json",
|
||||
response_schema=response_schema,
|
||||
max_output_tokens=max_output_tokens,
|
||||
temperature=0.4,
|
||||
top_p=0.8,
|
||||
thinking_config=genai_types.ThinkingConfig(
|
||||
thinking_level=genai_types.ThinkingLevel.LOW
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_gemini_client() -> tuple[genai.Client, str]:
|
||||
|
|
@ -19,3 +60,196 @@ def get_gemini_client() -> tuple[genai.Client, str]:
|
|||
raise HTTPException(status_code=503, detail="GEMINI_API_KEY not configured")
|
||||
model = os.environ.get("GEMINI_MODEL", _DEFAULT_MODEL)
|
||||
return genai.Client(api_key=api_key), model
|
||||
|
||||
|
||||
def call_gemini(
|
||||
*,
|
||||
endpoint: str,
|
||||
contents,
|
||||
config: genai_types.GenerateContentConfig,
|
||||
user_input: str | None = None,
|
||||
tool_trace: dict[str, Any] | None = None,
|
||||
):
|
||||
"""Call Gemini, log full request + response to DB, return response unchanged."""
|
||||
from sqlmodel import Session
|
||||
|
||||
from db import engine # deferred to avoid circular import at module load
|
||||
from innercontext.models.ai_log import AICallLog
|
||||
|
||||
client, model = get_gemini_client()
|
||||
|
||||
sys_prompt = None
|
||||
if config.system_instruction:
|
||||
raw = config.system_instruction
|
||||
sys_prompt = raw if isinstance(raw, str) else str(raw)
|
||||
if user_input is None:
|
||||
with suppress(Exception):
|
||||
user_input = str(contents)
|
||||
|
||||
start = time.monotonic()
|
||||
success, error_detail, response, finish_reason = True, None, None, None
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model=model, contents=contents, config=config
|
||||
)
|
||||
candidates = getattr(response, "candidates", None)
|
||||
if candidates:
|
||||
first_candidate = candidates[0]
|
||||
reason = getattr(first_candidate, "finish_reason", None)
|
||||
reason_name = getattr(reason, "name", None)
|
||||
if isinstance(reason_name, str):
|
||||
finish_reason = reason_name
|
||||
if finish_reason and finish_reason != "STOP":
|
||||
success = False
|
||||
error_detail = f"finish_reason: {finish_reason}"
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Gemini stopped early (finish_reason={finish_reason})",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
success = False
|
||||
error_detail = str(exc)
|
||||
raise HTTPException(status_code=502, detail=f"Gemini API error: {exc}") from exc
|
||||
finally:
|
||||
duration_ms = int((time.monotonic() - start) * 1000)
|
||||
with suppress(Exception):
|
||||
log = AICallLog(
|
||||
endpoint=endpoint,
|
||||
model=model,
|
||||
system_prompt=sys_prompt,
|
||||
user_input=user_input,
|
||||
response_text=response.text if response else None,
|
||||
tool_trace=tool_trace,
|
||||
prompt_tokens=(
|
||||
response.usage_metadata.prompt_token_count
|
||||
if response and response.usage_metadata
|
||||
else None
|
||||
),
|
||||
completion_tokens=(
|
||||
response.usage_metadata.candidates_token_count
|
||||
if response and response.usage_metadata
|
||||
else None
|
||||
),
|
||||
total_tokens=(
|
||||
response.usage_metadata.total_token_count
|
||||
if response and response.usage_metadata
|
||||
else None
|
||||
),
|
||||
duration_ms=duration_ms,
|
||||
finish_reason=finish_reason,
|
||||
success=success,
|
||||
error_detail=error_detail,
|
||||
)
|
||||
with Session(engine) as s:
|
||||
s.add(log)
|
||||
s.commit()
|
||||
return response
|
||||
|
||||
|
||||
def call_gemini_with_function_tools(
|
||||
*,
|
||||
endpoint: str,
|
||||
contents,
|
||||
config: genai_types.GenerateContentConfig,
|
||||
function_handlers: dict[str, Callable[[dict[str, Any]], dict[str, Any]]],
|
||||
user_input: str | None = None,
|
||||
max_tool_roundtrips: int = 2,
|
||||
):
|
||||
"""Call Gemini with function-calling loop until final response text is produced."""
|
||||
if max_tool_roundtrips < 0:
|
||||
raise ValueError("max_tool_roundtrips must be >= 0")
|
||||
|
||||
history = list(contents) if isinstance(contents, list) else [contents]
|
||||
rounds = 0
|
||||
trace_events: list[dict[str, Any]] = []
|
||||
|
||||
while True:
|
||||
response = call_gemini(
|
||||
endpoint=endpoint,
|
||||
contents=history,
|
||||
config=config,
|
||||
user_input=user_input,
|
||||
tool_trace={
|
||||
"mode": "function_tools",
|
||||
"round": rounds,
|
||||
"events": trace_events,
|
||||
},
|
||||
)
|
||||
function_calls = list(getattr(response, "function_calls", None) or [])
|
||||
if not function_calls:
|
||||
return response
|
||||
|
||||
if rounds >= max_tool_roundtrips:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Gemini requested too many function calls",
|
||||
)
|
||||
|
||||
candidate_content = None
|
||||
candidates = getattr(response, "candidates", None) or []
|
||||
if candidates:
|
||||
candidate_content = getattr(candidates[0], "content", None)
|
||||
if candidate_content is not None:
|
||||
history.append(candidate_content)
|
||||
else:
|
||||
history.append(
|
||||
genai_types.ModelContent(
|
||||
parts=[genai_types.Part(function_call=fc) for fc in function_calls]
|
||||
)
|
||||
)
|
||||
|
||||
response_parts: list[genai_types.Part] = []
|
||||
for fc in function_calls:
|
||||
name = getattr(fc, "name", None)
|
||||
if not isinstance(name, str) or not name:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Gemini requested a function without a valid name",
|
||||
)
|
||||
|
||||
handler = function_handlers.get(name)
|
||||
if handler is None:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Gemini requested unknown function: {name}",
|
||||
)
|
||||
|
||||
args = getattr(fc, "args", None) or {}
|
||||
if not isinstance(args, dict):
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Gemini returned invalid arguments for function: {name}",
|
||||
)
|
||||
|
||||
tool_response = handler(args)
|
||||
if not isinstance(tool_response, dict):
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Function handler must return an object for: {name}",
|
||||
)
|
||||
|
||||
trace_event: dict[str, Any] = {
|
||||
"round": rounds + 1,
|
||||
"function": name,
|
||||
}
|
||||
product_ids = args.get("product_ids")
|
||||
if isinstance(product_ids, list):
|
||||
clean_ids = [x for x in product_ids if isinstance(x, str)]
|
||||
trace_event["requested_ids_count"] = len(clean_ids)
|
||||
trace_event["requested_ids"] = clean_ids[:8]
|
||||
products = tool_response.get("products")
|
||||
if isinstance(products, list):
|
||||
trace_event["returned_products_count"] = len(products)
|
||||
trace_events.append(trace_event)
|
||||
|
||||
response_parts.append(
|
||||
genai_types.Part.from_function_response(
|
||||
name=name,
|
||||
response=tool_response,
|
||||
)
|
||||
)
|
||||
|
||||
history.append(genai_types.UserContent(parts=response_parts))
|
||||
rounds += 1
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
from .ai_log import AICallLog
|
||||
from .domain import Domain
|
||||
from .enums import (
|
||||
AbsorptionSpeed,
|
||||
|
|
@ -41,6 +42,8 @@ from .skincare import (
|
|||
)
|
||||
|
||||
__all__ = [
|
||||
# ai logs
|
||||
"AICallLog",
|
||||
# domain
|
||||
"Domain",
|
||||
# enums
|
||||
|
|
|
|||
33
backend/innercontext/models/ai_log.py
Normal file
33
backend/innercontext/models/ai_log.py
Normal 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)
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
|
@ -6,30 +7,27 @@ load_dotenv() # load .env before db.py reads DATABASE_URL
|
|||
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
||||
from fastmcp.utilities.lifespan import combine_lifespans # noqa: E402
|
||||
|
||||
from db import create_db_and_tables # noqa: E402
|
||||
from innercontext.api import ( # noqa: E402
|
||||
ai_logs,
|
||||
health,
|
||||
inventory,
|
||||
products,
|
||||
routines,
|
||||
skincare,
|
||||
)
|
||||
from innercontext.mcp_server import mcp # noqa: E402
|
||||
|
||||
mcp_app = mcp.http_app(path="/mcp")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
create_db_and_tables()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="innercontext API",
|
||||
lifespan=combine_lifespans(lifespan, mcp_app.lifespan),
|
||||
lifespan=lifespan,
|
||||
redirect_slashes=False,
|
||||
)
|
||||
|
||||
|
|
@ -45,9 +43,7 @@ app.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
|||
app.include_router(health.router, prefix="/health", tags=["health"])
|
||||
app.include_router(routines.router, prefix="/routines", tags=["routines"])
|
||||
app.include_router(skincare.router, prefix="/skincare", tags=["skincare"])
|
||||
|
||||
|
||||
app.mount("/mcp", mcp_app)
|
||||
app.include_router(ai_logs.router, prefix="/ai-logs", tags=["ai-logs"])
|
||||
|
||||
|
||||
@app.get("/health-check")
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ requires-python = ">=3.12"
|
|||
dependencies = [
|
||||
"alembic>=1.14",
|
||||
"fastapi>=0.132.0",
|
||||
"fastmcp>=2.0",
|
||||
"google-genai>=1.65.0",
|
||||
"psycopg>=3.3.3",
|
||||
"python-dotenv>=1.2.1",
|
||||
|
|
@ -22,6 +21,7 @@ dev = [
|
|||
"httpx>=0.28.1",
|
||||
"isort>=8.0.0",
|
||||
"pytest>=9.0.2",
|
||||
"pytest-cov>=6.0.0",
|
||||
"ruff>=0.15.2",
|
||||
"ty>=0.0.18",
|
||||
]
|
||||
|
|
@ -29,7 +29,7 @@ dev = [
|
|||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
addopts = "-v --tb=short"
|
||||
addopts = "-v --tb=short --cov=innercontext --cov-report=term-missing --cov-report=html"
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
|
|
|
|||
25
backend/test_query.py
Normal file
25
backend/test_query.py
Normal 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()
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
# Must be set before importing db (which calls create_engine at module level)
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite://")
|
||||
|
|
@ -14,18 +13,6 @@ from db import get_session
|
|||
from main import app
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _db_only_lifespan(a):
|
||||
"""Lifespan without the MCP server for test isolation.
|
||||
|
||||
StreamableHTTPSessionManager.run() can only be called once per instance,
|
||||
which conflicts with the per-test TestClient lifecycle. We replace the
|
||||
combined (db + MCP) lifespan with one that only does DB setup.
|
||||
"""
|
||||
db_module.create_db_and_tables()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session(monkeypatch):
|
||||
"""Per-test fresh SQLite in-memory database with full isolation."""
|
||||
|
|
@ -47,9 +34,6 @@ def session(monkeypatch):
|
|||
@pytest.fixture()
|
||||
def client(session, monkeypatch):
|
||||
"""TestClient using the per-test session for every request."""
|
||||
# Replace combined (db+MCP) lifespan with DB-only to avoid the
|
||||
# StreamableHTTPSessionManager single-run limitation.
|
||||
monkeypatch.setattr(app.router, "lifespan_context", _db_only_lifespan)
|
||||
|
||||
def _override():
|
||||
yield session
|
||||
|
|
|
|||
44
backend/tests/test_ai_logs.py
Normal file
44
backend/tests/test_ai_logs.py
Normal 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
|
||||
|
|
@ -199,3 +199,22 @@ def test_create_inventory(client, created_product):
|
|||
def test_create_inventory_product_not_found(client):
|
||||
r = client.post(f"/products/{uuid.uuid4()}/inventory", json={})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_parse_text_accepts_numeric_strength_levels(client, monkeypatch):
|
||||
from innercontext.api import products as products_api
|
||||
|
||||
class _FakeResponse:
|
||||
text = (
|
||||
'{"name":"Test Serum","actives":[{"name":"Niacinamide","percent":10,'
|
||||
'"functions":["niacinamide"],"strength_level":2,"irritation_potential":1}]}'
|
||||
)
|
||||
|
||||
monkeypatch.setattr(products_api, "call_gemini", lambda **kwargs: _FakeResponse())
|
||||
|
||||
r = client.post("/products/parse-text", json={"text": "dummy input"})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["name"] == "Test Serum"
|
||||
assert data["actives"][0]["strength_level"] == 2
|
||||
assert data["actives"][0]["irritation_potential"] == 1
|
||||
|
|
|
|||
157
backend/tests/test_products_helpers.py
Normal file
157
backend/tests/test_products_helpers.py
Normal 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]
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routines
|
||||
|
|
@ -216,3 +217,82 @@ def test_delete_grooming_schedule(client):
|
|||
def test_delete_grooming_schedule_not_found(client):
|
||||
r = client.delete(f"/routines/grooming-schedule/{uuid.uuid4()}")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_suggest_routine(client, session):
|
||||
with patch(
|
||||
"innercontext.api.routines.call_gemini_with_function_tools"
|
||||
) as mock_gemini:
|
||||
# Mock the Gemini response
|
||||
mock_response = type(
|
||||
"Response",
|
||||
(),
|
||||
{
|
||||
"text": '{"steps": [{"product_id": null, "action_type": "shaving_razor"}], "reasoning": "because"}'
|
||||
},
|
||||
)
|
||||
mock_gemini.return_value = mock_response
|
||||
|
||||
r = client.post(
|
||||
"/routines/suggest",
|
||||
json={
|
||||
"routine_date": "2026-03-03",
|
||||
"part_of_day": "am",
|
||||
"notes": "Testing",
|
||||
"include_minoxidil_beard": True,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data["steps"]) == 1
|
||||
assert data["steps"][0]["action_type"] == "shaving_razor"
|
||||
assert data["reasoning"] == "because"
|
||||
kwargs = mock_gemini.call_args.kwargs
|
||||
assert "function_handlers" in kwargs
|
||||
assert "get_product_inci" in kwargs["function_handlers"]
|
||||
assert "get_product_safety_rules" in kwargs["function_handlers"]
|
||||
assert "get_product_actives" in kwargs["function_handlers"]
|
||||
assert "get_product_usage_notes" in kwargs["function_handlers"]
|
||||
|
||||
|
||||
def test_suggest_batch(client, session):
|
||||
with patch("innercontext.api.routines.call_gemini") as mock_gemini:
|
||||
# Mock the Gemini response
|
||||
mock_response = type(
|
||||
"Response",
|
||||
(),
|
||||
{
|
||||
"text": '{"days": [{"date": "2026-03-03", "am_steps": [], "pm_steps": [], "reasoning": "none"}], "overall_reasoning": "batch test"}'
|
||||
},
|
||||
)
|
||||
mock_gemini.return_value = mock_response
|
||||
|
||||
r = client.post(
|
||||
"/routines/suggest-batch",
|
||||
json={
|
||||
"from_date": "2026-03-03",
|
||||
"to_date": "2026-03-04",
|
||||
"minimize_products": True,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data["days"]) == 1
|
||||
assert data["days"][0]["date"] == "2026-03-03"
|
||||
assert data["overall_reasoning"] == "batch test"
|
||||
|
||||
|
||||
def test_suggest_batch_invalid_date_range(client):
|
||||
r = client.post(
|
||||
"/routines/suggest-batch",
|
||||
json={"from_date": "2026-03-04", "to_date": "2026-03-03"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_suggest_batch_too_long(client):
|
||||
r = client.post(
|
||||
"/routines/suggest-batch",
|
||||
json={"from_date": "2026-03-01", "to_date": "2026-03-20"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
|
|
|||
408
backend/tests/test_routines_helpers.py
Normal file
408
backend/tests/test_routines_helpers.py
Normal 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
733
backend/uv.lock
generated
|
|
@ -2,18 +2,6 @@ version = 1
|
|||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "aiofile"
|
||||
version = "3.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "caio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
version = "1.18.4"
|
||||
|
|
@ -59,36 +47,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "25.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "authlib"
|
||||
version = "1.6.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/6c/c88eac87468c607f88bc24df1f3b31445ee6fc9ba123b09e666adf687cd9/authlib-1.6.8.tar.gz", hash = "sha256:41ae180a17cf672bc784e4a518e5c82687f1fe1e98b0cafaeda80c8e4ab2d1cb", size = 165074, upload-time = "2026-02-14T04:02:17.941Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/73/f7084bf12755113cd535ae586782ff3a6e710bfbe6a0d13d1c2f81ffbbfa/authlib-1.6.8-py2.py3-none-any.whl", hash = "sha256:97286fd7a15e6cfefc32771c8ef9c54f0ed58028f1322de6a2a7c969c3817888", size = 244116, upload-time = "2026-02-14T04:02:15.579Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "beartype"
|
||||
version = "0.22.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "26.1.0"
|
||||
|
|
@ -121,30 +79,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cachetools"
|
||||
version = "7.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d4/07/56595285564e90777d758ebd383d6b0b971b87729bbe2184a849932a3736/cachetools-7.0.1.tar.gz", hash = "sha256:e31e579d2c5b6e2944177a0397150d312888ddf4e16e12f1016068f0c03b8341", size = 36126, upload-time = "2026-02-10T22:24:05.03Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/9e/5faefbf9db1db466d633735faceda1f94aa99ce506ac450d232536266b32/cachetools-7.0.1-py3-none-any.whl", hash = "sha256:8f086515c254d5664ae2146d14fc7f65c9a4bce75152eb247e5a9c5e6d7b2ecf", size = 13484, upload-time = "2026-02-10T22:24:03.741Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "caio"
|
||||
version = "0.9.25"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.2.25"
|
||||
|
|
@ -289,6 +223,90 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.5"
|
||||
|
|
@ -342,21 +360,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cyclopts"
|
||||
version = "4.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "docstring-parser" },
|
||||
{ name = "rich" },
|
||||
{ name = "rich-rst" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/5c/88a4068c660a096bbe87efc5b7c190080c9e86919c36ec5f092cb08d852f/cyclopts-4.6.0.tar.gz", hash = "sha256:483c4704b953ea6da742e8de15972f405d2e748d19a848a4d61595e8e5360ee5", size = 162724, upload-time = "2026-02-23T15:44:49.286Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/eb/1e8337755a70dc7d7ff10a73dc8f20e9352c9ad6c2256ed863ac95cd3539/cyclopts-4.6.0-py3-none-any.whl", hash = "sha256:0a891cb55bfd79a3cdce024db8987b33316aba11071e5258c21ac12a640ba9f2", size = 200518, upload-time = "2026-02-23T15:44:47.854Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distro"
|
||||
version = "1.9.0"
|
||||
|
|
@ -366,58 +369,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "docstring-parser"
|
||||
version = "0.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "docutils"
|
||||
version = "0.22.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "dnspython" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.132.0"
|
||||
|
|
@ -434,37 +385,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/a8/de/6171c3363bbc5e01686e200e0880647c9270daa476d91030435cf14d32f5/fastapi-0.132.0-py3-none-any.whl", hash = "sha256:3c487d5afce196fa8ea509ae1531e96ccd5cdd2fd6eae78b73e2c20fba706689", size = 104652, upload-time = "2026-02-23T17:56:20.836Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastmcp"
|
||||
version = "3.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "authlib" },
|
||||
{ name = "cyclopts" },
|
||||
{ name = "exceptiongroup" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jsonref" },
|
||||
{ name = "jsonschema-path" },
|
||||
{ name = "mcp" },
|
||||
{ name = "openapi-pydantic" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "packaging" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
|
||||
{ name = "pydantic", extra = ["email"] },
|
||||
{ name = "pyperclip" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "rich" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/6b/1a7ec89727797fb07ec0928e9070fa2f45e7b35718e1fe01633a34c35e45/fastmcp-3.0.2.tar.gz", hash = "sha256:6bd73b4a3bab773ee6932df5249dcbcd78ed18365ed0aeeb97bb42702a7198d7", size = 17239351, upload-time = "2026-02-22T16:32:28.843Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/5a/f410a9015cfde71adf646dab4ef2feae49f92f34f6050fcfb265eb126b30/fastmcp-3.0.2-py3-none-any.whl", hash = "sha256:f513d80d4b30b54749fe8950116b1aab843f3c293f5cb971fc8665cb48dbb028", size = 606268, upload-time = "2026-02-22T16:32:30.992Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-auth"
|
||||
version = "2.48.0"
|
||||
|
|
@ -610,15 +530,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
|
|
@ -628,18 +539,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "importlib-metadata"
|
||||
version = "8.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "zipp" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
|
|
@ -656,7 +555,6 @@ source = { virtual = "." }
|
|||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "fastmcp" },
|
||||
{ name = "google-genai" },
|
||||
{ name = "psycopg" },
|
||||
{ name = "python-dotenv" },
|
||||
|
|
@ -671,6 +569,7 @@ dev = [
|
|||
{ name = "httpx" },
|
||||
{ name = "isort" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
{ name = "ty" },
|
||||
]
|
||||
|
|
@ -679,7 +578,6 @@ dev = [
|
|||
requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.14" },
|
||||
{ name = "fastapi", specifier = ">=0.132.0" },
|
||||
{ name = "fastmcp", specifier = ">=2.0" },
|
||||
{ name = "google-genai", specifier = ">=1.65.0" },
|
||||
{ name = "psycopg", specifier = ">=3.3.3" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
||||
|
|
@ -694,6 +592,7 @@ dev = [
|
|||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "isort", specifier = ">=8.0.0" },
|
||||
{ name = "pytest", specifier = ">=9.0.2" },
|
||||
{ name = "pytest-cov", specifier = ">=6.0.0" },
|
||||
{ name = "ruff", specifier = ">=0.15.2" },
|
||||
{ name = "ty", specifier = ">=0.0.18" },
|
||||
]
|
||||
|
|
@ -707,115 +606,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/74/ea/cf3aad99dd12c026e2d6835d559efb6fc50ccfd5b46d42d5fec2608b116a/isort-8.0.0-py3-none-any.whl", hash = "sha256:184916a933041c7cf718787f7e52064f3c06272aff69a5cb4dc46497bd8911d9", size = 89715, upload-time = "2026-02-19T16:31:57.745Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jaraco-classes"
|
||||
version = "3.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "more-itertools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jaraco-context"
|
||||
version = "6.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850, upload-time = "2026-01-13T02:53:53.847Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065, upload-time = "2026-01-13T02:53:53.031Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jaraco-functools"
|
||||
version = "4.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "more-itertools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jeepney"
|
||||
version = "0.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonref"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "jsonschema-specifications" },
|
||||
{ name = "referencing" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-path"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pathable" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "referencing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/b4/41315eea8301a5353bca3578792767135b8edbc081b20618a3f0b4d78307/jsonschema_path-0.4.4.tar.gz", hash = "sha256:4c55842890fc384262a59fb63a25c86cc0e2b059e929c18b851c1d19ef612026", size = 14923, upload-time = "2026-02-28T11:58:26.289Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/36/cb2cd6543776d02875de600f12fcd81611daf359544c9ad2abb12d3122a5/jsonschema_path-0.4.4-py3-none-any.whl", hash = "sha256:669bb69cb92cd4c54acf38ee2ff7c3d9ab6b69991698f7a2f17d2bb0e5c9c394", size = 19226, upload-time = "2026-02-28T11:58:25.143Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2025.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "referencing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "25.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jaraco-classes" },
|
||||
{ name = "jaraco-context" },
|
||||
{ name = "jaraco-functools" },
|
||||
{ name = "jeepney", marker = "sys_platform == 'linux'" },
|
||||
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
|
||||
{ name = "secretstorage", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.3.10"
|
||||
|
|
@ -828,18 +618,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
|
|
@ -903,49 +681,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx-sse" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "more-itertools"
|
||||
version = "10.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.1.0"
|
||||
|
|
@ -955,31 +690,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openapi-pydantic"
|
||||
version = "0.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
version = "1.39.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "importlib-metadata" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
|
|
@ -989,15 +699,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathable"
|
||||
version = "0.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", size = 16655, upload-time = "2026-02-20T08:47:00.748Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "1.0.4"
|
||||
|
|
@ -1038,31 +739,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "py-key-value-aio"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "beartype" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
filetree = [
|
||||
{ name = "aiofile" },
|
||||
{ name = "anyio" },
|
||||
]
|
||||
keyring = [
|
||||
{ name = "keyring" },
|
||||
]
|
||||
memory = [
|
||||
{ name = "cachetools" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.2"
|
||||
|
|
@ -1108,11 +784,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
email = [
|
||||
{ name = "email-validator" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.41.5"
|
||||
|
|
@ -1184,20 +855,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
|
|
@ -1207,29 +864,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
crypto = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyperclip"
|
||||
version = "1.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
|
|
@ -1246,6 +880,20 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.1"
|
||||
|
|
@ -1293,31 +941,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32"
|
||||
version = "311"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32-ctypes"
|
||||
version = "0.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
|
|
@ -1364,20 +987,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.37.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "rpds-py" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
|
|
@ -1393,113 +1002,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich-rst"
|
||||
version = "1.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "docutils" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "0.30.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "4.9.1"
|
||||
|
|
@ -1537,19 +1039,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secretstorage"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "jeepney" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
|
|
@ -1614,19 +1103,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/b1/e1/7c8d18e737433f3b5bbe27b56a9072a9fcb36342b48f1bef34b6da1d61f2/sqlmodel-0.0.37-py3-none-any.whl", hash = "sha256:2137a4045ef3fd66a917a7717ada959a1ceb3630d95e1f6aaab39dd2c0aef278", size = 27224, upload-time = "2026-02-21T16:39:47.781Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/c3695c2d2d4ef70072c3a06992850498b01c6bc9be531950813716b426fa/sse_starlette-3.3.2.tar.gz", hash = "sha256:678fca55a1945c734d8472a6cad186a55ab02840b4f6786f5ee8770970579dcd", size = 32326, upload-time = "2026-02-28T11:24:34.36Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl", hash = "sha256:5c3ea3dad425c601236726af2f27689b74494643f57017cafcb6f8c9acfbb862", size = 14270, upload-time = "2026-02-28T11:24:32.984Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.52.1"
|
||||
|
|
@ -1882,12 +1358,3 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zipp"
|
||||
version = "3.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
|
||||
]
|
||||
|
|
|
|||
63
deploy.sh
Executable file
63
deploy.sh
Executable 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."
|
||||
|
|
@ -7,13 +7,16 @@ Reverse proxy (existing) innercontext LXC (new, Debian 13)
|
|||
┌──────────────────────┐ ┌────────────────────────────────────┐
|
||||
│ reverse proxy │────────────▶│ nginx :80 │
|
||||
│ innercontext.lan → * │ │ /api/* → uvicorn :8000/* │
|
||||
└──────────────────────┘ │ /mcp/* → uvicorn :8000/mcp/* │
|
||||
│ /* → SvelteKit Node :3000 │
|
||||
└──────────────────────┘ │ /* → SvelteKit Node :3000 │
|
||||
└────────────────────────────────────┘
|
||||
│ │
|
||||
FastAPI + MCP SvelteKit Node
|
||||
FastAPI SvelteKit Node
|
||||
```
|
||||
|
||||
> **Frontend is never built on the server.** The `vite build` + `adapter-node`
|
||||
> esbuild step is CPU/RAM-intensive and will hang on a small LXC. Build locally,
|
||||
> deploy the `build/` artifact via `deploy.sh`.
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
- Proxmox VE host with an existing PostgreSQL LXC and a reverse proxy
|
||||
|
|
@ -53,7 +56,7 @@ pct enter 200 # or SSH into the container
|
|||
|
||||
```bash
|
||||
apt update && apt upgrade -y
|
||||
apt install -y git nginx curl ca-certificates gnupg lsb-release libpq5
|
||||
apt install -y git nginx curl ca-certificates gnupg lsb-release libpq5 rsync
|
||||
```
|
||||
|
||||
### Python 3.12+ + uv
|
||||
|
|
@ -67,6 +70,11 @@ Installing to `/usr/local/bin` makes `uv` available system-wide (required for `s
|
|||
|
||||
### Node.js 24 LTS + pnpm
|
||||
|
||||
The server needs Node.js to **run** the pre-built frontend bundle, and pnpm to
|
||||
**install production runtime dependencies** (`clsx`, `bits-ui`, etc. —
|
||||
`adapter-node` bundles the SvelteKit framework but leaves these external).
|
||||
The frontend is never **built** on the server.
|
||||
|
||||
```bash
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
|
||||
. "$HOME/.nvm/nvm.sh"
|
||||
|
|
@ -75,16 +83,14 @@ nvm install 24
|
|||
|
||||
Copy `node` to `/usr/local/bin` so it is accessible system-wide
|
||||
(required for `sudo -u innercontext` and for systemd).
|
||||
Symlinking into `/root/.nvm/` won't work — other users can't traverse `/root/`.
|
||||
Use `--remove-destination` to replace any existing symlink with a real file:
|
||||
|
||||
```bash
|
||||
cp --remove-destination "$(nvm which current)" /usr/local/bin/node
|
||||
```
|
||||
|
||||
Install pnpm as a standalone binary from GitHub releases — self-contained,
|
||||
no wrapper scripts, works system-wide. Do **not** use `corepack enable pnpm`
|
||||
(the shim requires its nvm directory structure and breaks when copied/linked):
|
||||
Install pnpm as a standalone binary — self-contained, no wrapper scripts,
|
||||
works system-wide:
|
||||
|
||||
```bash
|
||||
curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linux-x64" \
|
||||
|
|
@ -194,11 +200,10 @@ systemctl status innercontext
|
|||
|
||||
---
|
||||
|
||||
## 7. Frontend build and setup
|
||||
## 7. Frontend setup
|
||||
|
||||
```bash
|
||||
cd /opt/innercontext/frontend
|
||||
```
|
||||
The frontend is **built locally and uploaded** via `deploy.sh` — never built on the server.
|
||||
This section only covers the one-time server-side configuration.
|
||||
|
||||
### Create `.env.production`
|
||||
|
||||
|
|
@ -211,25 +216,24 @@ chmod 600 /opt/innercontext/frontend/.env.production
|
|||
chown innercontext:innercontext /opt/innercontext/frontend/.env.production
|
||||
```
|
||||
|
||||
### Install dependencies and build
|
||||
### Grant `innercontext` passwordless sudo for service restarts
|
||||
|
||||
```bash
|
||||
sudo -u innercontext bash -c '
|
||||
cd /opt/innercontext/frontend
|
||||
pnpm install
|
||||
PUBLIC_API_BASE=http://innercontext.lan/api pnpm build
|
||||
'
|
||||
cat > /etc/sudoers.d/innercontext-deploy << 'EOF'
|
||||
innercontext ALL=(root) NOPASSWD: \
|
||||
/usr/bin/systemctl restart innercontext, \
|
||||
/usr/bin/systemctl restart innercontext-node
|
||||
EOF
|
||||
chmod 440 /etc/sudoers.d/innercontext-deploy
|
||||
```
|
||||
|
||||
The production build lands in `/opt/innercontext/frontend/build/`.
|
||||
|
||||
### Install systemd service
|
||||
|
||||
```bash
|
||||
cp /opt/innercontext/systemd/innercontext-node.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now innercontext-node
|
||||
systemctl status innercontext-node
|
||||
systemctl enable innercontext-node
|
||||
# Do NOT start yet — build/ is empty until the first deploy.sh run
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -276,44 +280,64 @@ Reload your reverse proxy after applying the change.
|
|||
|
||||
---
|
||||
|
||||
## 10. Verification
|
||||
## 10. First deploy from local machine
|
||||
|
||||
All subsequent deploys (including the first one) use `deploy.sh` from your local machine.
|
||||
|
||||
### SSH config
|
||||
|
||||
Add to `~/.ssh/config` on your local machine:
|
||||
|
||||
```
|
||||
Host innercontext
|
||||
HostName <innercontext-lxc-ip>
|
||||
User innercontext
|
||||
```
|
||||
|
||||
Make sure your SSH public key is in `/home/innercontext/.ssh/authorized_keys` on the server.
|
||||
|
||||
### Run the first deploy
|
||||
|
||||
```bash
|
||||
# From the repo root on your local machine:
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Build the frontend locally (`pnpm run build`)
|
||||
2. Upload `frontend/build/` to the server via rsync
|
||||
3. Restart `innercontext-node`
|
||||
4. Upload `backend/` source to the server
|
||||
5. Run `uv sync --frozen` on the server
|
||||
6. Restart `innercontext` (runs alembic migrations on start)
|
||||
|
||||
---
|
||||
|
||||
## 11. Verification
|
||||
|
||||
```bash
|
||||
# From any machine on the LAN:
|
||||
curl http://innercontext.lan/api/health-check # {"status":"ok"}
|
||||
curl http://innercontext.lan/api/products # []
|
||||
curl http://innercontext.lan/ # SvelteKit HTML shell
|
||||
curl -N http://innercontext.lan/mcp/mcp # MCP StreamableHTTP endpoint
|
||||
```
|
||||
|
||||
The web UI should be accessible at `http://innercontext.lan`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Updating the application
|
||||
## 12. Updating the application
|
||||
|
||||
```bash
|
||||
cd /opt/innercontext
|
||||
git pull
|
||||
|
||||
# Sync backend dependencies if pyproject.toml changed:
|
||||
cd backend && sudo -u innercontext uv sync && cd ..
|
||||
|
||||
# Apply any new DB migrations (runs automatically via ExecStartPre, but safe to run manually first):
|
||||
sudo -u innercontext bash -c 'cd /opt/innercontext/backend && uv run alembic upgrade head'
|
||||
|
||||
# Rebuild frontend:
|
||||
cd frontend && sudo -u innercontext bash -c '
|
||||
pnpm install
|
||||
PUBLIC_API_BASE=http://innercontext.lan/api pnpm build
|
||||
'
|
||||
|
||||
systemctl restart innercontext innercontext-node
|
||||
# From the repo root on your local machine:
|
||||
./deploy.sh # full deploy (frontend + backend)
|
||||
./deploy.sh frontend # frontend only
|
||||
./deploy.sh backend # backend only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Troubleshooting
|
||||
## 13. Troubleshooting
|
||||
|
||||
### 502 Bad Gateway on `/api/*`
|
||||
|
||||
|
|
@ -328,17 +352,7 @@ journalctl -u innercontext -n 50
|
|||
```bash
|
||||
systemctl status innercontext-node
|
||||
journalctl -u innercontext-node -n 50
|
||||
# Verify /opt/innercontext/frontend/build/index.js exists (pnpm build ran successfully)
|
||||
```
|
||||
|
||||
### MCP endpoint not responding
|
||||
|
||||
```bash
|
||||
# MCP uses SSE — disable buffering is already in nginx config
|
||||
# Verify the backend started successfully:
|
||||
curl http://127.0.0.1:8000/health-check
|
||||
# Check FastAPI logs:
|
||||
journalctl -u innercontext -n 50
|
||||
# Verify /opt/innercontext/frontend/build/index.js exists (deploy.sh ran successfully)
|
||||
```
|
||||
|
||||
### Database connection refused
|
||||
|
|
|
|||
10
frontend/.mcp.json
Normal file
10
frontend/.mcp.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"svelte": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"env": {},
|
||||
"args": ["-y", "@sveltejs/mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
8
frontend/.prettierignore
Normal file
8
frontend/.prettierignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
.svelte-kit
|
||||
paraglide
|
||||
build
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
9
frontend/.prettierrc
Normal file
9
frontend/.prettierrc
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"plugins": ["svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.svelte"],
|
||||
"parser": "svelte-eslint-parser"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -24,8 +24,8 @@ The backend must be running at `http://localhost:8000`. See `../backend/` for se
|
|||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| Variable | Description | Default |
|
||||
| ----------------- | ------------------------------- | ----------------------- |
|
||||
| `PUBLIC_API_BASE` | Base URL of the FastAPI backend | `http://localhost:8000` |
|
||||
|
||||
Set `PUBLIC_API_BASE` at **build time** for production:
|
||||
|
|
@ -51,24 +51,24 @@ Or use the provided systemd service: `../systemd/innercontext-node.service`.
|
|||
|
||||
## Routes
|
||||
|
||||
| Route | Description |
|
||||
|---|---|
|
||||
| `/` | Dashboard |
|
||||
| `/products` | Product list |
|
||||
| `/products/new` | Add product |
|
||||
| `/products/[id]` | Product detail / edit |
|
||||
| `/routines` | Routine list |
|
||||
| `/routines/new` | Create routine |
|
||||
| `/routines/[id]` | Routine detail |
|
||||
| `/health/medications` | Medications |
|
||||
| `/health/lab-results` | Lab results |
|
||||
| `/skin` | Skin condition snapshots |
|
||||
| Route | Description |
|
||||
| --------------------- | ------------------------ |
|
||||
| `/` | Dashboard |
|
||||
| `/products` | Product list |
|
||||
| `/products/new` | Add product |
|
||||
| `/products/[id]` | Product detail / edit |
|
||||
| `/routines` | Routine list |
|
||||
| `/routines/new` | Create routine |
|
||||
| `/routines/[id]` | Routine detail |
|
||||
| `/health/medications` | Medications |
|
||||
| `/health/lab-results` | Lab results |
|
||||
| `/skin` | Skin condition snapshots |
|
||||
|
||||
## Key files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `src/lib/api.ts` | API client (typed fetch wrappers) |
|
||||
| `src/lib/types.ts` | Shared TypeScript types |
|
||||
| `src/app.css` | Tailwind v4 theme + global styles |
|
||||
| `svelte.config.js` | SvelteKit config (adapter-node) |
|
||||
| File | Purpose |
|
||||
| ------------------ | --------------------------------- |
|
||||
| `src/lib/api.ts` | API client (typed fetch wrappers) |
|
||||
| `src/lib/types.ts` | Shared TypeScript types |
|
||||
| `src/app.css` | Tailwind v4 theme + global styles |
|
||||
| `svelte.config.js` | SvelteKit config (adapter-node) |
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"style": "default",
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app.css",
|
||||
"baseColor": "zinc"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"style": "default",
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app.css",
|
||||
"baseColor": "zinc"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
}
|
||||
|
|
|
|||
47
frontend/eslint.config.js
Normal file
47
frontend/eslint.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -1,437 +1,522 @@
|
|||
{
|
||||
"nav_dashboard": "Dashboard",
|
||||
"nav_products": "Products",
|
||||
"nav_routines": "Routines",
|
||||
"nav_grooming": "Grooming",
|
||||
"nav_medications": "Medications",
|
||||
"nav_labResults": "Lab Results",
|
||||
"nav_skin": "Skin",
|
||||
"nav_appName": "innercontext",
|
||||
"nav_appSubtitle": "personal health & skincare",
|
||||
"nav_dashboard": "Dashboard",
|
||||
"nav_products": "Products",
|
||||
"nav_routines": "Routines",
|
||||
"nav_grooming": "Grooming",
|
||||
"nav_medications": "Medications",
|
||||
"nav_labResults": "Lab Results",
|
||||
"nav_skin": "Skin",
|
||||
"nav_appName": "innercontext",
|
||||
"nav_appSubtitle": "personal health & skincare",
|
||||
|
||||
"common_save": "Save",
|
||||
"common_cancel": "Cancel",
|
||||
"common_add": "Add",
|
||||
"common_edit": "Edit",
|
||||
"common_delete": "Delete",
|
||||
"common_saved": "Saved.",
|
||||
"common_select": "Select",
|
||||
"common_unknown": "Unknown",
|
||||
"common_yes": "Yes",
|
||||
"common_no": "No",
|
||||
"common_unknown_value": "Unknown",
|
||||
"common_optional_notes": "optional",
|
||||
"common_steps": "steps",
|
||||
"common_save": "Save",
|
||||
"common_cancel": "Cancel",
|
||||
"common_add": "Add",
|
||||
"common_edit": "Edit",
|
||||
"common_delete": "Delete",
|
||||
"common_saved": "Saved.",
|
||||
"common_select": "Select",
|
||||
"common_unknown": "Unknown",
|
||||
"common_yes": "Yes",
|
||||
"common_no": "No",
|
||||
"common_unknown_value": "Unknown",
|
||||
"common_optional_notes": "optional",
|
||||
"common_steps": "steps",
|
||||
|
||||
"dashboard_title": "Dashboard",
|
||||
"dashboard_subtitle": "Your recent health & skincare overview",
|
||||
"dashboard_latestSnapshot": "Latest Skin Snapshot",
|
||||
"dashboard_recentRoutines": "Recent Routines",
|
||||
"dashboard_noSnapshots": "No skin snapshots yet.",
|
||||
"dashboard_noRoutines": "No routines in the past 2 weeks.",
|
||||
"dashboard_title": "Dashboard",
|
||||
"dashboard_subtitle": "Your recent health & skincare overview",
|
||||
"dashboard_latestSnapshot": "Latest Skin Snapshot",
|
||||
"dashboard_recentRoutines": "Recent Routines",
|
||||
"dashboard_noSnapshots": "No skin snapshots yet.",
|
||||
"dashboard_noRoutines": "No routines in the past 2 weeks.",
|
||||
|
||||
"products_title": "Products",
|
||||
"products_count": "{count} products",
|
||||
"products_addNew": "+ Add product",
|
||||
"products_noProducts": "No products found.",
|
||||
"products_filterAll": "All",
|
||||
"products_filterOwned": "Owned",
|
||||
"products_filterUnowned": "Not owned",
|
||||
"products_colName": "Name",
|
||||
"products_colBrand": "Brand",
|
||||
"products_colTargets": "Targets",
|
||||
"products_colTime": "Time",
|
||||
"products_newTitle": "New Product",
|
||||
"products_backToList": "← Products",
|
||||
"products_createProduct": "Create product",
|
||||
"products_saveChanges": "Save changes",
|
||||
"products_deleteProduct": "Delete product",
|
||||
"products_confirmDelete": "Delete this product?",
|
||||
"products_noInventory": "No inventory packages.",
|
||||
"products_title": "Products",
|
||||
"products_count": [
|
||||
{
|
||||
"declarations": ["input count", "local countPlural = count: plural"],
|
||||
"selectors": ["countPlural"],
|
||||
"match": {
|
||||
"countPlural=one": "{count} product",
|
||||
"countPlural=*": "{count} products"
|
||||
}
|
||||
}
|
||||
],
|
||||
"products_addNew": "+ Add product",
|
||||
"products_suggest": "Suggest",
|
||||
"products_suggestTitle": "Shopping suggestions",
|
||||
"products_suggestSubtitle": "What to buy?",
|
||||
"products_suggestDescription": "Based on your skin condition and products you own, I'll suggest product types that could complement your routine.",
|
||||
"products_suggestGenerating": "Analyzing...",
|
||||
"products_suggestBtn": "Generate suggestions",
|
||||
"products_suggestResults": "Suggestions",
|
||||
"products_suggestTime": "Time",
|
||||
"products_suggestFrequency": "Frequency",
|
||||
"products_suggestRegenerate": "Regenerate",
|
||||
"products_suggestNoResults": "No suggestions.",
|
||||
"products_noProducts": "No products found.",
|
||||
"products_filterAll": "All",
|
||||
"products_filterOwned": "Owned",
|
||||
"products_filterUnowned": "Not owned",
|
||||
"products_colName": "Name",
|
||||
"products_colBrand": "Brand",
|
||||
"products_colTargets": "Targets",
|
||||
"products_colTime": "Time",
|
||||
"products_newTitle": "New Product",
|
||||
"products_backToList": "← Products",
|
||||
"products_createProduct": "Create product",
|
||||
"products_saveChanges": "Save changes",
|
||||
"products_deleteProduct": "Delete product",
|
||||
"products_confirmDelete": "Delete this product?",
|
||||
"products_noInventory": "No inventory packages.",
|
||||
|
||||
"inventory_title": "Inventory packages ({count})",
|
||||
"inventory_addPackage": "+ Add package",
|
||||
"inventory_packageAdded": "Package added.",
|
||||
"inventory_packageUpdated": "Package updated.",
|
||||
"inventory_packageDeleted": "Package deleted.",
|
||||
"inventory_alreadyOpened": "Already opened",
|
||||
"inventory_openedDate": "Opened date",
|
||||
"inventory_finishedDate": "Finished date",
|
||||
"inventory_expiryDate": "Expiry date",
|
||||
"inventory_currentWeight": "Current weight (g)",
|
||||
"inventory_lastWeighed": "Last weighed",
|
||||
"inventory_notes": "Notes",
|
||||
"inventory_badgeOpen": "Open",
|
||||
"inventory_badgeSealed": "Sealed",
|
||||
"inventory_badgeFinished": "Finished",
|
||||
"inventory_exp": "Exp:",
|
||||
"inventory_opened": "Opened:",
|
||||
"inventory_finished": "Finished:",
|
||||
"inventory_remaining": "g remaining",
|
||||
"inventory_weighed": "Weighed:",
|
||||
"inventory_confirmDelete": "Delete this package?",
|
||||
"inventory_title": "Inventory packages ({count})",
|
||||
"inventory_addPackage": "+ Add package",
|
||||
"inventory_packageAdded": "Package added.",
|
||||
"inventory_packageUpdated": "Package updated.",
|
||||
"inventory_packageDeleted": "Package deleted.",
|
||||
"inventory_alreadyOpened": "Already opened",
|
||||
"inventory_openedDate": "Opened date",
|
||||
"inventory_finishedDate": "Finished date",
|
||||
"inventory_expiryDate": "Expiry date",
|
||||
"inventory_currentWeight": "Current weight (g)",
|
||||
"inventory_lastWeighed": "Last weighed",
|
||||
"inventory_notes": "Notes",
|
||||
"inventory_badgeOpen": "Open",
|
||||
"inventory_badgeSealed": "Sealed",
|
||||
"inventory_badgeFinished": "Finished",
|
||||
"inventory_exp": "Exp:",
|
||||
"inventory_opened": "Opened:",
|
||||
"inventory_finished": "Finished:",
|
||||
"inventory_remaining": "g remaining",
|
||||
"inventory_weighed": "Weighed:",
|
||||
"inventory_confirmDelete": "Delete this package?",
|
||||
|
||||
"routines_title": "Routines",
|
||||
"routines_count": "{count} routines (last 30 days)",
|
||||
"routines_suggestAI": "Suggest AI routine",
|
||||
"routines_addNew": "+ New routine",
|
||||
"routines_noRoutines": "No routines found.",
|
||||
"routines_newTitle": "New Routine",
|
||||
"routines_backToList": "← Routines",
|
||||
"routines_detailsTitle": "Routine details",
|
||||
"routines_date": "Date *",
|
||||
"routines_amOrPm": "AM or PM *",
|
||||
"routines_notes": "Notes",
|
||||
"routines_notesPlaceholder": "Optional notes",
|
||||
"routines_createRoutine": "Create routine",
|
||||
"routines_deleteRoutine": "Delete routine",
|
||||
"routines_confirmDelete": "Delete this routine?",
|
||||
"routines_steps": "Steps ({count})",
|
||||
"routines_addStep": "+ Add step",
|
||||
"routines_addStepTitle": "Add step",
|
||||
"routines_product": "Product",
|
||||
"routines_selectProduct": "Select product",
|
||||
"routines_dose": "Dose",
|
||||
"routines_dosePlaceholder": "e.g. 2 pumps",
|
||||
"routines_region": "Region",
|
||||
"routines_regionPlaceholder": "e.g. face",
|
||||
"routines_addStepBtn": "Add step",
|
||||
"routines_unknownStep": "Unknown step",
|
||||
"routines_noSteps": "No steps yet.",
|
||||
"routines_title": "Routines",
|
||||
"routines_count": [
|
||||
{
|
||||
"declarations": ["input count", "local countPlural = count: plural"],
|
||||
"selectors": ["countPlural"],
|
||||
"match": {
|
||||
"countPlural=one": "{count} routine (last 30 days)",
|
||||
"countPlural=*": "{count} routines (last 30 days)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"routines_suggestAI": "Suggest AI routine",
|
||||
"routines_addNew": "+ New routine",
|
||||
"routines_noRoutines": "No routines found.",
|
||||
"routines_newTitle": "New Routine",
|
||||
"routines_backToList": "← Routines",
|
||||
"routines_detailsTitle": "Routine details",
|
||||
"routines_date": "Date *",
|
||||
"routines_amOrPm": "AM or PM *",
|
||||
"routines_notes": "Notes",
|
||||
"routines_notesPlaceholder": "Optional notes",
|
||||
"routines_createRoutine": "Create routine",
|
||||
"routines_deleteRoutine": "Delete routine",
|
||||
"routines_confirmDelete": "Delete this routine?",
|
||||
"routines_steps": "Steps ({count})",
|
||||
"routines_addStep": "+ Add step",
|
||||
"routines_addStepTitle": "Add step",
|
||||
"routines_product": "Product",
|
||||
"routines_selectProduct": "Select product",
|
||||
"routines_dose": "Dose",
|
||||
"routines_dosePlaceholder": "e.g. 2 pumps",
|
||||
"routines_region": "Region",
|
||||
"routines_regionPlaceholder": "e.g. face",
|
||||
"routines_addStepBtn": "Add step",
|
||||
"routines_unknownStep": "Unknown step",
|
||||
"routines_noSteps": "No steps yet.",
|
||||
|
||||
"grooming_title": "Grooming Schedule",
|
||||
"grooming_backToRoutines": "← Routines",
|
||||
"grooming_addEntry": "+ Add entry",
|
||||
"grooming_entryAdded": "Entry added.",
|
||||
"grooming_entryUpdated": "Entry updated.",
|
||||
"grooming_entryDeleted": "Entry deleted.",
|
||||
"grooming_dayOfWeek": "Day of week",
|
||||
"grooming_action": "Action",
|
||||
"grooming_notesOptional": "Notes (optional)",
|
||||
"grooming_notesPlaceholder": "e.g. every 2 weeks",
|
||||
"grooming_noEntries": "No entries yet. Click \"+ Add entry\" to get started.",
|
||||
"grooming_confirmDelete": "Delete this entry?",
|
||||
"grooming_actionShavingRazor": "Razor shaving",
|
||||
"grooming_actionShavingOneblade": "OneBlade shaving",
|
||||
"grooming_actionDermarolling": "Dermarolling",
|
||||
"grooming_dayMonday": "Monday",
|
||||
"grooming_dayTuesday": "Tuesday",
|
||||
"grooming_dayWednesday": "Wednesday",
|
||||
"grooming_dayThursday": "Thursday",
|
||||
"grooming_dayFriday": "Friday",
|
||||
"grooming_daySaturday": "Saturday",
|
||||
"grooming_daySunday": "Sunday",
|
||||
"grooming_title": "Grooming Schedule",
|
||||
"grooming_backToRoutines": "← Routines",
|
||||
"grooming_addEntry": "+ Add entry",
|
||||
"grooming_entryAdded": "Entry added.",
|
||||
"grooming_entryUpdated": "Entry updated.",
|
||||
"grooming_entryDeleted": "Entry deleted.",
|
||||
"grooming_dayOfWeek": "Day of week",
|
||||
"grooming_action": "Action",
|
||||
"grooming_notesOptional": "Notes (optional)",
|
||||
"grooming_notesPlaceholder": "e.g. every 2 weeks",
|
||||
"grooming_noEntries": "No entries yet. Click \"+ Add entry\" to get started.",
|
||||
"grooming_confirmDelete": "Delete this entry?",
|
||||
"grooming_actionShavingRazor": "Razor shaving",
|
||||
"grooming_actionShavingOneblade": "OneBlade shaving",
|
||||
"grooming_actionDermarolling": "Dermarolling",
|
||||
"grooming_dayMonday": "Monday",
|
||||
"grooming_dayTuesday": "Tuesday",
|
||||
"grooming_dayWednesday": "Wednesday",
|
||||
"grooming_dayThursday": "Thursday",
|
||||
"grooming_dayFriday": "Friday",
|
||||
"grooming_daySaturday": "Saturday",
|
||||
"grooming_daySunday": "Sunday",
|
||||
|
||||
"suggest_title": "AI Routine Suggestion",
|
||||
"suggest_backToRoutines": "← Routines",
|
||||
"suggest_singleTab": "Single routine",
|
||||
"suggest_batchTab": "Batch / Vacation",
|
||||
"suggest_singleParams": "Parameters",
|
||||
"suggest_date": "Date",
|
||||
"suggest_timeOfDay": "Time of day",
|
||||
"suggest_contextLabel": "Additional context for AI",
|
||||
"suggest_contextOptional": "(optional)",
|
||||
"suggest_contextPlaceholder": "e.g. party night, focusing on hydration...",
|
||||
"suggest_generateBtn": "Generate suggestion",
|
||||
"suggest_generating": "Generating…",
|
||||
"suggest_proposalTitle": "Suggestion",
|
||||
"suggest_saveRoutine": "Save routine",
|
||||
"suggest_saving": "Saving…",
|
||||
"suggest_regenerate": "Regenerate",
|
||||
"suggest_batchRange": "Date range",
|
||||
"suggest_fromDate": "From",
|
||||
"suggest_toDate": "To (max 14 days)",
|
||||
"suggest_batchContextLabel": "Context / trip purpose",
|
||||
"suggest_batchContextPlaceholder": "e.g. sunny trip to Italy, active mountain vacation...",
|
||||
"suggest_generatePlan": "Generate plan",
|
||||
"suggest_generatingPlan": "Generating plan…",
|
||||
"suggest_planTitle": "Plan ({count} days)",
|
||||
"suggest_saveAllRoutines": "Save all routines",
|
||||
"suggest_amSteps": "steps",
|
||||
"suggest_pmSteps": "steps",
|
||||
"suggest_noAmSteps": "No AM steps.",
|
||||
"suggest_noPmSteps": "No PM steps.",
|
||||
"suggest_errorDefault": "Error generating suggestion.",
|
||||
"suggest_errorBatch": "Error generating plan.",
|
||||
"suggest_errorSave": "Error saving.",
|
||||
"suggest_amMorning": "AM (morning)",
|
||||
"suggest_pmEvening": "PM (evening)",
|
||||
"suggest_title": "AI Routine Suggestion",
|
||||
"suggest_backToRoutines": "← Routines",
|
||||
"suggest_singleTab": "Single routine",
|
||||
"suggest_batchTab": "Batch / Vacation",
|
||||
"suggest_singleParams": "Parameters",
|
||||
"suggest_date": "Date",
|
||||
"suggest_timeOfDay": "Time of day",
|
||||
"suggest_contextLabel": "Additional context for AI",
|
||||
"suggest_contextOptional": "(optional)",
|
||||
"suggest_contextPlaceholder": "e.g. party night, focusing on hydration...",
|
||||
"suggest_leavingHomeLabel": "Going outside today",
|
||||
"suggest_leavingHomeHint": "Affects SPF selection — checked: SPF50+, unchecked: SPF30.",
|
||||
"suggest_minoxidilToggleLabel": "Prioritize beard/mustache density (minoxidil)",
|
||||
"suggest_minoxidilToggleHint": "When enabled, AI will explicitly consider minoxidil for beard/mustache areas if available.",
|
||||
"suggest_generateBtn": "Generate suggestion",
|
||||
"suggest_generating": "Generating…",
|
||||
"suggest_proposalTitle": "Suggestion",
|
||||
"suggest_saveRoutine": "Save routine",
|
||||
"suggest_saving": "Saving…",
|
||||
"suggest_regenerate": "Regenerate",
|
||||
"suggest_batchRange": "Date range",
|
||||
"suggest_fromDate": "From",
|
||||
"suggest_toDate": "To (max 14 days)",
|
||||
"suggest_batchContextLabel": "Context / trip purpose",
|
||||
"suggest_batchContextPlaceholder": "e.g. sunny trip to Italy, active mountain vacation...",
|
||||
"suggest_generatePlan": "Generate plan",
|
||||
"suggest_generatingPlan": "Generating plan…",
|
||||
"suggest_planTitle": [
|
||||
{
|
||||
"declarations": ["input count", "local countPlural = count: plural"],
|
||||
"selectors": ["countPlural"],
|
||||
"match": {
|
||||
"countPlural=one": "Plan ({count} day)",
|
||||
"countPlural=*": "Plan ({count} days)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"suggest_saveAllRoutines": "Save all routines",
|
||||
"suggest_amSteps": "steps",
|
||||
"suggest_pmSteps": "steps",
|
||||
"suggest_noAmSteps": "No AM steps.",
|
||||
"suggest_noPmSteps": "No PM steps.",
|
||||
"suggest_errorDefault": "Error generating suggestion.",
|
||||
"suggest_errorBatch": "Error generating plan.",
|
||||
"suggest_errorSave": "Error saving.",
|
||||
"suggest_amMorning": "AM (morning)",
|
||||
"suggest_pmEvening": "PM (evening)",
|
||||
"suggest_summaryPrimaryGoal": "Primary goal",
|
||||
"suggest_summaryConfidence": "Confidence",
|
||||
"suggest_summaryConstraints": "Constraints",
|
||||
"suggest_stepOptionalBadge": "optional",
|
||||
|
||||
"medications_title": "Medications",
|
||||
"medications_count": "{count} entries",
|
||||
"medications_addNew": "+ Add medication",
|
||||
"medications_newTitle": "New medication",
|
||||
"medications_kind": "Kind",
|
||||
"medications_productName": "Product name *",
|
||||
"medications_productNamePlaceholder": "e.g. Vitamin D3",
|
||||
"medications_activeSubstance": "Active substance",
|
||||
"medications_activeSubstancePlaceholder": "e.g. cholecalciferol",
|
||||
"medications_notes": "Notes",
|
||||
"medications_added": "Medication added.",
|
||||
"medications_usages": "{count} usages",
|
||||
"medications_noMedications": "No medications recorded.",
|
||||
"medications_kindPrescription": "Prescription",
|
||||
"medications_kindOtc": "OTC",
|
||||
"medications_kindSupplement": "Supplement",
|
||||
"medications_kindHerbal": "Herbal",
|
||||
"medications_kindOther": "Other",
|
||||
"medications_title": "Medications",
|
||||
"medications_count": [
|
||||
{
|
||||
"declarations": ["input count", "local countPlural = count: plural"],
|
||||
"selectors": ["countPlural"],
|
||||
"match": {
|
||||
"countPlural=one": "{count} entry",
|
||||
"countPlural=*": "{count} entries"
|
||||
}
|
||||
}
|
||||
],
|
||||
"medications_addNew": "+ Add medication",
|
||||
"medications_newTitle": "New medication",
|
||||
"medications_kind": "Kind",
|
||||
"medications_productName": "Product name *",
|
||||
"medications_productNamePlaceholder": "e.g. Vitamin D3",
|
||||
"medications_activeSubstance": "Active substance",
|
||||
"medications_activeSubstancePlaceholder": "e.g. cholecalciferol",
|
||||
"medications_notes": "Notes",
|
||||
"medications_added": "Medication added.",
|
||||
"medications_usages": [
|
||||
{
|
||||
"declarations": ["input count", "local countPlural = count: plural"],
|
||||
"selectors": ["countPlural"],
|
||||
"match": {
|
||||
"countPlural=one": "{count} usage",
|
||||
"countPlural=*": "{count} usages"
|
||||
}
|
||||
}
|
||||
],
|
||||
"medications_noMedications": "No medications recorded.",
|
||||
"medications_kindPrescription": "Prescription",
|
||||
"medications_kindOtc": "OTC",
|
||||
"medications_kindSupplement": "Supplement",
|
||||
"medications_kindHerbal": "Herbal",
|
||||
"medications_kindOther": "Other",
|
||||
|
||||
"labResults_title": "Lab Results",
|
||||
"labResults_count": "{count} results",
|
||||
"labResults_addNew": "+ Add result",
|
||||
"labResults_newTitle": "New lab result",
|
||||
"labResults_flagFilter": "Flag:",
|
||||
"labResults_flagAll": "All",
|
||||
"labResults_flagNone": "None",
|
||||
"labResults_date": "Date *",
|
||||
"labResults_loincCode": "LOINC code *",
|
||||
"labResults_testName": "Test name",
|
||||
"labResults_testNamePlaceholder": "e.g. Hemoglobin",
|
||||
"labResults_lab": "Lab",
|
||||
"labResults_labPlaceholder": "e.g. LabCorp",
|
||||
"labResults_value": "Value",
|
||||
"labResults_unit": "Unit",
|
||||
"labResults_unitPlaceholder": "e.g. g/dL",
|
||||
"labResults_flag": "Flag",
|
||||
"labResults_added": "Result added.",
|
||||
"labResults_colDate": "Date",
|
||||
"labResults_colTest": "Test",
|
||||
"labResults_colLoinc": "LOINC",
|
||||
"labResults_colValue": "Value",
|
||||
"labResults_colFlag": "Flag",
|
||||
"labResults_colLab": "Lab",
|
||||
"labResults_noResults": "No lab results found.",
|
||||
"labResults_title": "Lab Results",
|
||||
"labResults_count": [
|
||||
{
|
||||
"declarations": ["input count", "local countPlural = count: plural"],
|
||||
"selectors": ["countPlural"],
|
||||
"match": {
|
||||
"countPlural=one": "{count} result",
|
||||
"countPlural=*": "{count} results"
|
||||
}
|
||||
}
|
||||
],
|
||||
"labResults_addNew": "+ Add result",
|
||||
"labResults_newTitle": "New lab result",
|
||||
"labResults_flagFilter": "Flag:",
|
||||
"labResults_flagAll": "All",
|
||||
"labResults_flagNone": "None",
|
||||
"labResults_date": "Date *",
|
||||
"labResults_loincCode": "LOINC code *",
|
||||
"labResults_testName": "Test name",
|
||||
"labResults_testNamePlaceholder": "e.g. Hemoglobin",
|
||||
"labResults_lab": "Lab",
|
||||
"labResults_labPlaceholder": "e.g. LabCorp",
|
||||
"labResults_value": "Value",
|
||||
"labResults_unit": "Unit",
|
||||
"labResults_unitPlaceholder": "e.g. g/dL",
|
||||
"labResults_flag": "Flag",
|
||||
"labResults_added": "Result added.",
|
||||
"labResults_colDate": "Date",
|
||||
"labResults_colTest": "Test",
|
||||
"labResults_colLoinc": "LOINC",
|
||||
"labResults_colValue": "Value",
|
||||
"labResults_colFlag": "Flag",
|
||||
"labResults_colLab": "Lab",
|
||||
"labResults_noResults": "No lab results found.",
|
||||
|
||||
"skin_title": "Skin Snapshots",
|
||||
"skin_count": "{count} snapshots",
|
||||
"skin_addNew": "+ Add snapshot",
|
||||
"skin_aiAnalysisTitle": "AI analysis from photos",
|
||||
"skin_aiUploadText": "Upload 1–3 photos of your skin. AI will pre-fill the form fields below.",
|
||||
"skin_analyzePhotos": "Analyze photos",
|
||||
"skin_analyzing": "Analyzing…",
|
||||
"skin_newSnapshotTitle": "New skin snapshot",
|
||||
"skin_date": "Date *",
|
||||
"skin_overallState": "Overall state",
|
||||
"skin_texture": "Texture",
|
||||
"skin_skinType": "Skin type",
|
||||
"skin_barrierState": "Barrier state",
|
||||
"skin_hydration": "Hydration (1–5)",
|
||||
"skin_sensitivity": "Sensitivity (1–5)",
|
||||
"skin_sebumTzone": "Sebum T-zone (1–5)",
|
||||
"skin_sebumCheeks": "Sebum cheeks (1–5)",
|
||||
"skin_activeConcerns": "Active concerns (comma-separated)",
|
||||
"skin_activeConcernsPlaceholder": "acne, redness, dehydration",
|
||||
"skin_notes": "Notes",
|
||||
"skin_addSnapshot": "Add snapshot",
|
||||
"skin_snapshotAdded": "Snapshot added.",
|
||||
"skin_snapshotUpdated": "Snapshot updated.",
|
||||
"skin_snapshotDeleted": "Snapshot deleted.",
|
||||
"skin_noSnapshots": "No skin snapshots yet.",
|
||||
"skin_hydrationLabel": "Hydration",
|
||||
"skin_sensitivityLabel": "Sensitivity",
|
||||
"skin_barrierLabel": "Barrier",
|
||||
"skin_stateExcellent": "excellent",
|
||||
"skin_stateGood": "good",
|
||||
"skin_stateFair": "fair",
|
||||
"skin_statePoor": "poor",
|
||||
"skin_textureSmooth": "smooth",
|
||||
"skin_textureRough": "rough",
|
||||
"skin_textureFlaky": "flaky",
|
||||
"skin_textureBumpy": "bumpy",
|
||||
"skin_barrierIntact": "intact",
|
||||
"skin_barrierMildly": "mildly compromised",
|
||||
"skin_barrierCompromised": "compromised",
|
||||
"skin_typeDry": "dry",
|
||||
"skin_typeOily": "oily",
|
||||
"skin_typeCombination": "combination",
|
||||
"skin_typeSensitive": "sensitive",
|
||||
"skin_typeNormal": "normal",
|
||||
"skin_typeAcneProne": "acne prone",
|
||||
"skin_title": "Skin Snapshots",
|
||||
"skin_count": [
|
||||
{
|
||||
"declarations": ["input count", "local countPlural = count: plural"],
|
||||
"selectors": ["countPlural"],
|
||||
"match": {
|
||||
"countPlural=one": "{count} snapshot",
|
||||
"countPlural=*": "{count} snapshots"
|
||||
}
|
||||
}
|
||||
],
|
||||
"skin_addNew": "+ Add snapshot",
|
||||
"skin_aiAnalysisTitle": "AI analysis from photos",
|
||||
"skin_aiUploadText": "Upload 1–3 photos of your skin. AI will pre-fill the form fields below.",
|
||||
"skin_analyzePhotos": "Analyze photos",
|
||||
"skin_analyzing": "Analyzing…",
|
||||
"skin_newSnapshotTitle": "New skin snapshot",
|
||||
"skin_date": "Date *",
|
||||
"skin_overallState": "Overall state",
|
||||
"skin_texture": "Texture",
|
||||
"skin_skinType": "Skin type",
|
||||
"skin_barrierState": "Barrier state",
|
||||
"skin_hydration": "Hydration (1–5)",
|
||||
"skin_sensitivity": "Sensitivity (1–5)",
|
||||
"skin_sebumTzone": "Sebum T-zone (1–5)",
|
||||
"skin_sebumCheeks": "Sebum cheeks (1–5)",
|
||||
"skin_activeConcerns": "Active concerns (comma-separated)",
|
||||
"skin_activeConcernsPlaceholder": "acne, redness, dehydration",
|
||||
"skin_priorities": "Priorities (comma-separated)",
|
||||
"skin_prioritiesPlaceholder": "strengthen barrier, reduce redness",
|
||||
"skin_prioritiesLabel": "Priorities",
|
||||
"skin_notes": "Notes",
|
||||
"skin_addSnapshot": "Add snapshot",
|
||||
"skin_snapshotAdded": "Snapshot added.",
|
||||
"skin_snapshotUpdated": "Snapshot updated.",
|
||||
"skin_snapshotDeleted": "Snapshot deleted.",
|
||||
"skin_noSnapshots": "No skin snapshots yet.",
|
||||
"skin_hydrationLabel": "Hydration",
|
||||
"skin_sensitivityLabel": "Sensitivity",
|
||||
"skin_barrierLabel": "Barrier",
|
||||
"skin_stateExcellent": "excellent",
|
||||
"skin_stateGood": "good",
|
||||
"skin_stateFair": "fair",
|
||||
"skin_statePoor": "poor",
|
||||
"skin_textureSmooth": "smooth",
|
||||
"skin_textureRough": "rough",
|
||||
"skin_textureFlaky": "flaky",
|
||||
"skin_textureBumpy": "bumpy",
|
||||
"skin_barrierIntact": "intact",
|
||||
"skin_barrierMildly": "mildly compromised",
|
||||
"skin_barrierCompromised": "compromised",
|
||||
"skin_typeDry": "dry",
|
||||
"skin_typeOily": "oily",
|
||||
"skin_typeCombination": "combination",
|
||||
"skin_typeSensitive": "sensitive",
|
||||
"skin_typeNormal": "normal",
|
||||
"skin_typeAcneProne": "acne prone",
|
||||
|
||||
"productForm_aiPrefill": "AI pre-fill",
|
||||
"productForm_aiPrefillText": "Paste product description from a website, ingredient list, or other text. AI will fill in available fields — you can review and correct before saving.",
|
||||
"productForm_pasteText": "Paste product description, INCI ingredients here...",
|
||||
"productForm_parseWithAI": "Fill fields (AI)",
|
||||
"productForm_parsing": "Processing…",
|
||||
"productForm_basicInfo": "Basic info",
|
||||
"productForm_name": "Name *",
|
||||
"productForm_namePlaceholder": "e.g. Hydro Boost Water Gel",
|
||||
"productForm_brand": "Brand *",
|
||||
"productForm_brandPlaceholder": "e.g. Neutrogena",
|
||||
"productForm_lineName": "Line / series",
|
||||
"productForm_lineNamePlaceholder": "e.g. Hydro Boost",
|
||||
"productForm_url": "URL",
|
||||
"productForm_sku": "SKU",
|
||||
"productForm_skuPlaceholder": "e.g. NTR-HB-50",
|
||||
"productForm_barcode": "Barcode / EAN",
|
||||
"productForm_barcodePlaceholder": "e.g. 3614273258975",
|
||||
"productForm_classification": "Classification",
|
||||
"productForm_category": "Category *",
|
||||
"productForm_selectCategory": "Select category",
|
||||
"productForm_time": "Time *",
|
||||
"productForm_timeOptions": "AM / PM / Both",
|
||||
"productForm_timeBoth": "Both",
|
||||
"productForm_leaveOn": "Leave-on *",
|
||||
"productForm_leaveOnYes": "Yes (leave-on)",
|
||||
"productForm_leaveOnNo": "No (rinse-off)",
|
||||
"productForm_texture": "Texture",
|
||||
"productForm_selectTexture": "Select texture",
|
||||
"productForm_absorptionSpeed": "Absorption speed",
|
||||
"productForm_selectSpeed": "Select speed",
|
||||
"productForm_skinProfile": "Skin profile",
|
||||
"productForm_recommendedFor": "Recommended for skin types",
|
||||
"productForm_targetConcerns": "Target concerns",
|
||||
"productForm_contraindications": "Contraindications (one per line)",
|
||||
"productForm_contraindicationsPlaceholder": "e.g. active rosacea flares",
|
||||
"productForm_ingredients": "Ingredients",
|
||||
"productForm_inciList": "INCI list (one ingredient per line)",
|
||||
"productForm_activeIngredients": "Active ingredients",
|
||||
"productForm_addActive": "+ Add active",
|
||||
"productForm_noActives": "No actives added yet.",
|
||||
"productForm_activeName": "Name",
|
||||
"productForm_activePercent": "%",
|
||||
"productForm_activeStrength": "Strength",
|
||||
"productForm_activeIrritation": "Irritation",
|
||||
"productForm_activeFunctions": "Functions",
|
||||
"productForm_effectProfile": "Effect profile (0–5)",
|
||||
"productForm_interactions": "Interactions",
|
||||
"productForm_synergizesWith": "Synergizes with (one per line)",
|
||||
"productForm_incompatibleWith": "Incompatible with",
|
||||
"productForm_addIncompatibility": "+ Add incompatibility",
|
||||
"productForm_noIncompatibilities": "No incompatibilities added.",
|
||||
"productForm_incompTarget": "Target ingredient",
|
||||
"productForm_incompScope": "Scope",
|
||||
"productForm_incompReason": "Reason (optional)",
|
||||
"productForm_incompReasonPlaceholder": "e.g. reduces efficacy",
|
||||
"productForm_incompScopeSelect": "Select…",
|
||||
"productForm_contextRules": "Context rules",
|
||||
"productForm_ctxAfterShaving": "Safe after shaving",
|
||||
"productForm_ctxAfterAcids": "Safe after acids",
|
||||
"productForm_ctxAfterRetinoids": "Safe after retinoids",
|
||||
"productForm_ctxCompromisedBarrier": "Safe with compromised barrier",
|
||||
"productForm_ctxLowUvOnly": "Low UV only (evening/covered)",
|
||||
"productForm_productDetails": "Product details",
|
||||
"productForm_priceTier": "Price tier",
|
||||
"productForm_selectTier": "Select tier",
|
||||
"productForm_sizeMl": "Size (ml)",
|
||||
"productForm_fullWeightG": "Full weight (g)",
|
||||
"productForm_emptyWeightG": "Empty weight (g)",
|
||||
"productForm_paoMonths": "PAO (months)",
|
||||
"productForm_phMin": "pH min",
|
||||
"productForm_phMax": "pH max",
|
||||
"productForm_usageNotes": "Usage notes",
|
||||
"productForm_usageNotesPlaceholder": "e.g. Apply to damp skin, avoid eye area",
|
||||
"productForm_safetyFlags": "Safety flags",
|
||||
"productForm_fragranceFree": "Fragrance-free",
|
||||
"productForm_essentialOilsFree": "Essential oils-free",
|
||||
"productForm_alcoholDenatFree": "Alcohol denat-free",
|
||||
"productForm_pregnancySafe": "Pregnancy safe",
|
||||
"productForm_usageConstraints": "Usage constraints",
|
||||
"productForm_minIntervalHours": "Min interval (hours)",
|
||||
"productForm_maxFrequencyPerWeek": "Max uses per week",
|
||||
"productForm_isMedication": "Is medication",
|
||||
"productForm_isTool": "Is tool (e.g. dermaroller)",
|
||||
"productForm_needleLengthMm": "Needle length (mm, tools only)",
|
||||
"productForm_personalNotes": "Personal notes",
|
||||
"productForm_repurchaseIntent": "Repurchase intent",
|
||||
"productForm_toleranceNotes": "Tolerance notes",
|
||||
"productForm_toleranceNotesPlaceholder": "e.g. Causes mild stinging, fine after 2 weeks",
|
||||
"productForm_aiPrefill": "AI pre-fill",
|
||||
"productForm_aiPrefillText": "Paste product description from a website, ingredient list, or other text. AI will fill in available fields — you can review and correct before saving.",
|
||||
"productForm_pasteText": "Paste product description, INCI ingredients here...",
|
||||
"productForm_parseWithAI": "Fill fields (AI)",
|
||||
"productForm_parsing": "Processing…",
|
||||
"productForm_basicInfo": "Basic info",
|
||||
"productForm_name": "Name *",
|
||||
"productForm_namePlaceholder": "e.g. Hydro Boost Water Gel",
|
||||
"productForm_brand": "Brand *",
|
||||
"productForm_brandPlaceholder": "e.g. Neutrogena",
|
||||
"productForm_lineName": "Line / series",
|
||||
"productForm_lineNamePlaceholder": "e.g. Hydro Boost",
|
||||
"productForm_url": "URL",
|
||||
"productForm_sku": "SKU",
|
||||
"productForm_skuPlaceholder": "e.g. NTR-HB-50",
|
||||
"productForm_barcode": "Barcode / EAN",
|
||||
"productForm_barcodePlaceholder": "e.g. 3614273258975",
|
||||
"productForm_classification": "Classification",
|
||||
"productForm_category": "Category *",
|
||||
"productForm_selectCategory": "Select category",
|
||||
"productForm_time": "Time *",
|
||||
"productForm_timeOptions": "AM / PM / Both",
|
||||
"productForm_timeBoth": "Both",
|
||||
"productForm_leaveOn": "Leave-on *",
|
||||
"productForm_leaveOnYes": "Yes (leave-on)",
|
||||
"productForm_leaveOnNo": "No (rinse-off)",
|
||||
"productForm_texture": "Texture",
|
||||
"productForm_selectTexture": "Select texture",
|
||||
"productForm_absorptionSpeed": "Absorption speed",
|
||||
"productForm_selectSpeed": "Select speed",
|
||||
"productForm_skinProfile": "Skin profile",
|
||||
"productForm_recommendedFor": "Recommended for skin types",
|
||||
"productForm_targetConcerns": "Target concerns",
|
||||
"productForm_contraindications": "Contraindications (one per line)",
|
||||
"productForm_contraindicationsPlaceholder": "e.g. active rosacea flares",
|
||||
"productForm_ingredients": "Ingredients",
|
||||
"productForm_inciList": "INCI list (one ingredient per line)",
|
||||
"productForm_activeIngredients": "Active ingredients",
|
||||
"productForm_addActive": "+ Add active",
|
||||
"productForm_noActives": "No actives added yet.",
|
||||
"productForm_activeName": "Name",
|
||||
"productForm_activePercent": "%",
|
||||
"productForm_activeStrength": "Strength",
|
||||
"productForm_activeIrritation": "Irritation",
|
||||
"productForm_activeFunctions": "Functions",
|
||||
"productForm_effectProfile": "Effect profile (0–5)",
|
||||
"productForm_interactions": "Interactions",
|
||||
"productForm_synergizesWith": "Synergizes with (one per line)",
|
||||
"productForm_incompatibleWith": "Incompatible with",
|
||||
"productForm_addIncompatibility": "+ Add incompatibility",
|
||||
"productForm_noIncompatibilities": "No incompatibilities added.",
|
||||
"productForm_incompTarget": "Target ingredient",
|
||||
"productForm_incompScope": "Scope",
|
||||
"productForm_incompReason": "Reason (optional)",
|
||||
"productForm_incompReasonPlaceholder": "e.g. reduces efficacy",
|
||||
"productForm_incompScopeSelect": "Select…",
|
||||
"productForm_contextRules": "Context rules",
|
||||
"productForm_ctxAfterShaving": "Safe after shaving",
|
||||
"productForm_ctxAfterAcids": "Safe after acids",
|
||||
"productForm_ctxAfterRetinoids": "Safe after retinoids",
|
||||
"productForm_ctxCompromisedBarrier": "Safe with compromised barrier",
|
||||
"productForm_ctxLowUvOnly": "Low UV only (evening/covered)",
|
||||
"productForm_productDetails": "Product details",
|
||||
"productForm_priceTier": "Price tier",
|
||||
"productForm_selectTier": "Select tier",
|
||||
"productForm_sizeMl": "Size (ml)",
|
||||
"productForm_fullWeightG": "Full weight (g)",
|
||||
"productForm_emptyWeightG": "Empty weight (g)",
|
||||
"productForm_paoMonths": "PAO (months)",
|
||||
"productForm_phMin": "pH min",
|
||||
"productForm_phMax": "pH max",
|
||||
"productForm_usageNotes": "Usage notes",
|
||||
"productForm_usageNotesPlaceholder": "e.g. Apply to damp skin, avoid eye area",
|
||||
"productForm_safetyFlags": "Safety flags",
|
||||
"productForm_fragranceFree": "Fragrance-free",
|
||||
"productForm_essentialOilsFree": "Essential oils-free",
|
||||
"productForm_alcoholDenatFree": "Alcohol denat-free",
|
||||
"productForm_pregnancySafe": "Pregnancy safe",
|
||||
"productForm_usageConstraints": "Usage constraints",
|
||||
"productForm_minIntervalHours": "Min interval (hours)",
|
||||
"productForm_maxFrequencyPerWeek": "Max uses per week",
|
||||
"productForm_isMedication": "Is medication",
|
||||
"productForm_isTool": "Is tool (e.g. dermaroller)",
|
||||
"productForm_needleLengthMm": "Needle length (mm, tools only)",
|
||||
"productForm_personalNotes": "Personal notes",
|
||||
"productForm_repurchaseIntent": "Repurchase intent",
|
||||
"productForm_toleranceNotes": "Tolerance notes",
|
||||
"productForm_toleranceNotesPlaceholder": "e.g. Causes mild stinging, fine after 2 weeks",
|
||||
|
||||
"productForm_categoryCleanser": "Cleanser",
|
||||
"productForm_categoryToner": "Toner",
|
||||
"productForm_categoryEssence": "Essence",
|
||||
"productForm_categorySerum": "Serum",
|
||||
"productForm_categoryMoisturizer": "Moisturizer",
|
||||
"productForm_categorySpf": "SPF",
|
||||
"productForm_categoryMask": "Mask",
|
||||
"productForm_categoryExfoliant": "Exfoliant",
|
||||
"productForm_categoryHairTreatment": "Hair treatment",
|
||||
"productForm_categoryTool": "Tool",
|
||||
"productForm_categorySpotTreatment": "Spot treatment",
|
||||
"productForm_categoryOil": "Oil",
|
||||
"productForm_categoryCleanser": "Cleanser",
|
||||
"productForm_categoryToner": "Toner",
|
||||
"productForm_categoryEssence": "Essence",
|
||||
"productForm_categorySerum": "Serum",
|
||||
"productForm_categoryMoisturizer": "Moisturizer",
|
||||
"productForm_categorySpf": "SPF",
|
||||
"productForm_categoryMask": "Mask",
|
||||
"productForm_categoryExfoliant": "Exfoliant",
|
||||
"productForm_categoryHairTreatment": "Hair treatment",
|
||||
"productForm_categoryTool": "Tool",
|
||||
"productForm_categorySpotTreatment": "Spot treatment",
|
||||
"productForm_categoryOil": "Oil",
|
||||
|
||||
"productForm_textureWatery": "Watery",
|
||||
"productForm_textureGel": "Gel",
|
||||
"productForm_textureEmulsion": "Emulsion",
|
||||
"productForm_textureCream": "Cream",
|
||||
"productForm_textureOil": "Oil",
|
||||
"productForm_textureBalm": "Balm",
|
||||
"productForm_textureFoam": "Foam",
|
||||
"productForm_textureFluid": "Fluid",
|
||||
"productForm_textureWatery": "Watery",
|
||||
"productForm_textureGel": "Gel",
|
||||
"productForm_textureEmulsion": "Emulsion",
|
||||
"productForm_textureCream": "Cream",
|
||||
"productForm_textureOil": "Oil",
|
||||
"productForm_textureBalm": "Balm",
|
||||
"productForm_textureFoam": "Foam",
|
||||
"productForm_textureFluid": "Fluid",
|
||||
|
||||
"productForm_absorptionVeryFast": "Very fast",
|
||||
"productForm_absorptionFast": "Fast",
|
||||
"productForm_absorptionModerate": "Moderate",
|
||||
"productForm_absorptionSlow": "Slow",
|
||||
"productForm_absorptionVerySlow": "Very slow",
|
||||
"productForm_absorptionVeryFast": "Very fast",
|
||||
"productForm_absorptionFast": "Fast",
|
||||
"productForm_absorptionModerate": "Moderate",
|
||||
"productForm_absorptionSlow": "Slow",
|
||||
"productForm_absorptionVerySlow": "Very slow",
|
||||
|
||||
"productForm_priceBudget": "Budget",
|
||||
"productForm_priceMid": "Mid",
|
||||
"productForm_pricePremium": "Premium",
|
||||
"productForm_priceLuxury": "Luxury",
|
||||
"productForm_priceBudget": "Budget",
|
||||
"productForm_priceMid": "Mid",
|
||||
"productForm_pricePremium": "Premium",
|
||||
"productForm_priceLuxury": "Luxury",
|
||||
|
||||
"productForm_skinTypeDry": "dry",
|
||||
"productForm_skinTypeOily": "oily",
|
||||
"productForm_skinTypeCombination": "combination",
|
||||
"productForm_skinTypeSensitive": "sensitive",
|
||||
"productForm_skinTypeNormal": "normal",
|
||||
"productForm_skinTypeAcneProne": "acne prone",
|
||||
"productForm_skinTypeDry": "dry",
|
||||
"productForm_skinTypeOily": "oily",
|
||||
"productForm_skinTypeCombination": "combination",
|
||||
"productForm_skinTypeSensitive": "sensitive",
|
||||
"productForm_skinTypeNormal": "normal",
|
||||
"productForm_skinTypeAcneProne": "acne prone",
|
||||
|
||||
"productForm_concernAcne": "acne",
|
||||
"productForm_concernRosacea": "rosacea",
|
||||
"productForm_concernHyperpigmentation": "hyperpigmentation",
|
||||
"productForm_concernAging": "aging",
|
||||
"productForm_concernDehydration": "dehydration",
|
||||
"productForm_concernRedness": "redness",
|
||||
"productForm_concernDamagedBarrier": "damaged barrier",
|
||||
"productForm_concernPoreVisibility": "pore visibility",
|
||||
"productForm_concernUnevenTexture": "uneven texture",
|
||||
"productForm_concernHairGrowth": "hair growth",
|
||||
"productForm_concernSebumExcess": "sebum excess",
|
||||
"productForm_concernAcne": "acne",
|
||||
"productForm_concernRosacea": "rosacea",
|
||||
"productForm_concernHyperpigmentation": "hyperpigmentation",
|
||||
"productForm_concernAging": "aging",
|
||||
"productForm_concernDehydration": "dehydration",
|
||||
"productForm_concernRedness": "redness",
|
||||
"productForm_concernDamagedBarrier": "damaged barrier",
|
||||
"productForm_concernPoreVisibility": "pore visibility",
|
||||
"productForm_concernUnevenTexture": "uneven texture",
|
||||
"productForm_concernHairGrowth": "hair growth",
|
||||
"productForm_concernSebumExcess": "sebum excess",
|
||||
|
||||
"productForm_fnHumectant": "humectant",
|
||||
"productForm_fnEmollient": "emollient",
|
||||
"productForm_fnOcclusive": "occlusive",
|
||||
"productForm_fnExfoliantAha": "AHA exfoliant",
|
||||
"productForm_fnExfoliantBha": "BHA exfoliant",
|
||||
"productForm_fnExfoliantPha": "PHA exfoliant",
|
||||
"productForm_fnRetinoid": "retinoid",
|
||||
"productForm_fnAntioxidant": "antioxidant",
|
||||
"productForm_fnSoothing": "soothing",
|
||||
"productForm_fnBarrierSupport": "barrier support",
|
||||
"productForm_fnBrightening": "brightening",
|
||||
"productForm_fnAntiAcne": "anti-acne",
|
||||
"productForm_fnCeramide": "ceramide",
|
||||
"productForm_fnNiacinamide": "niacinamide",
|
||||
"productForm_fnSunscreen": "sunscreen",
|
||||
"productForm_fnPeptide": "peptide",
|
||||
"productForm_fnHairGrowth": "hair growth stimulant",
|
||||
"productForm_fnPrebiotic": "prebiotic",
|
||||
"productForm_fnVitaminC": "vitamin C",
|
||||
"productForm_fnAntiAging": "anti-aging",
|
||||
"productForm_fnHumectant": "humectant",
|
||||
"productForm_fnEmollient": "emollient",
|
||||
"productForm_fnOcclusive": "occlusive",
|
||||
"productForm_fnExfoliantAha": "AHA exfoliant",
|
||||
"productForm_fnExfoliantBha": "BHA exfoliant",
|
||||
"productForm_fnExfoliantPha": "PHA exfoliant",
|
||||
"productForm_fnRetinoid": "retinoid",
|
||||
"productForm_fnAntioxidant": "antioxidant",
|
||||
"productForm_fnSoothing": "soothing",
|
||||
"productForm_fnBarrierSupport": "barrier support",
|
||||
"productForm_fnBrightening": "brightening",
|
||||
"productForm_fnAntiAcne": "anti-acne",
|
||||
"productForm_fnCeramide": "ceramide",
|
||||
"productForm_fnNiacinamide": "niacinamide",
|
||||
"productForm_fnSunscreen": "sunscreen",
|
||||
"productForm_fnPeptide": "peptide",
|
||||
"productForm_fnHairGrowth": "hair growth stimulant",
|
||||
"productForm_fnPrebiotic": "prebiotic",
|
||||
"productForm_fnVitaminC": "vitamin C",
|
||||
"productForm_fnAntiAging": "anti-aging",
|
||||
|
||||
"productForm_scopeSameStep": "same step",
|
||||
"productForm_scopeSameDay": "same day",
|
||||
"productForm_scopeSamePeriod": "same period",
|
||||
"productForm_scopeSameStep": "same step",
|
||||
"productForm_scopeSameDay": "same day",
|
||||
"productForm_scopeSamePeriod": "same period",
|
||||
|
||||
"productForm_strengthLow": "1 Low",
|
||||
"productForm_strengthMedium": "2 Medium",
|
||||
"productForm_strengthHigh": "3 High",
|
||||
"productForm_strengthLow": "1 Low",
|
||||
"productForm_strengthMedium": "2 Medium",
|
||||
"productForm_strengthHigh": "3 High",
|
||||
|
||||
"productForm_effectHydrationImmediate": "Hydration (immediate)",
|
||||
"productForm_effectHydrationLongTerm": "Hydration (long term)",
|
||||
"productForm_effectBarrierRepair": "Barrier repair",
|
||||
"productForm_effectSoothing": "Soothing",
|
||||
"productForm_effectExfoliation": "Exfoliation",
|
||||
"productForm_effectRetinoid": "Retinoid activity",
|
||||
"productForm_effectIrritation": "Irritation risk",
|
||||
"productForm_effectComedogenic": "Comedogenic risk",
|
||||
"productForm_effectBarrierDisruption": "Barrier disruption risk",
|
||||
"productForm_effectDryness": "Dryness risk",
|
||||
"productForm_effectBrightening": "Brightening",
|
||||
"productForm_effectAntiAcne": "Anti-acne",
|
||||
"productForm_effectAntiAging": "Anti-aging",
|
||||
"productForm_effectHydrationImmediate": "Hydration (immediate)",
|
||||
"productForm_effectHydrationLongTerm": "Hydration (long term)",
|
||||
"productForm_effectBarrierRepair": "Barrier repair",
|
||||
"productForm_effectSoothing": "Soothing",
|
||||
"productForm_effectExfoliation": "Exfoliation",
|
||||
"productForm_effectRetinoid": "Retinoid activity",
|
||||
"productForm_effectIrritation": "Irritation risk",
|
||||
"productForm_effectComedogenic": "Comedogenic risk",
|
||||
"productForm_effectBarrierDisruption": "Barrier disruption risk",
|
||||
"productForm_effectDryness": "Dryness risk",
|
||||
"productForm_effectBrightening": "Brightening",
|
||||
"productForm_effectAntiAcne": "Anti-acne",
|
||||
"productForm_effectAntiAging": "Anti-aging",
|
||||
|
||||
"lang_pl": "PL",
|
||||
"lang_en": "EN"
|
||||
"lang_pl": "PL",
|
||||
"lang_en": "EN"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,437 +1,536 @@
|
|||
{
|
||||
"nav_dashboard": "Dashboard",
|
||||
"nav_products": "Produkty",
|
||||
"nav_routines": "Rutyny",
|
||||
"nav_grooming": "Pielęgnacja",
|
||||
"nav_medications": "Leki",
|
||||
"nav_labResults": "Wyniki badań",
|
||||
"nav_skin": "Skóra",
|
||||
"nav_appName": "innercontext",
|
||||
"nav_appSubtitle": "zdrowie & pielęgnacja",
|
||||
"nav_dashboard": "Dashboard",
|
||||
"nav_products": "Produkty",
|
||||
"nav_routines": "Rutyny",
|
||||
"nav_grooming": "Pielęgnacja",
|
||||
"nav_medications": "Leki",
|
||||
"nav_labResults": "Wyniki badań",
|
||||
"nav_skin": "Skóra",
|
||||
"nav_appName": "innercontext",
|
||||
"nav_appSubtitle": "zdrowie & pielęgnacja",
|
||||
|
||||
"common_save": "Zapisz",
|
||||
"common_cancel": "Anuluj",
|
||||
"common_add": "Dodaj",
|
||||
"common_edit": "Edytuj",
|
||||
"common_delete": "Usuń",
|
||||
"common_saved": "Zapisano.",
|
||||
"common_select": "Wybierz",
|
||||
"common_unknown": "Nieznane",
|
||||
"common_yes": "Tak",
|
||||
"common_no": "Nie",
|
||||
"common_unknown_value": "Nieznane",
|
||||
"common_optional_notes": "opcjonalnie",
|
||||
"common_steps": "kroków",
|
||||
"common_save": "Zapisz",
|
||||
"common_cancel": "Anuluj",
|
||||
"common_add": "Dodaj",
|
||||
"common_edit": "Edytuj",
|
||||
"common_delete": "Usuń",
|
||||
"common_saved": "Zapisano.",
|
||||
"common_select": "Wybierz",
|
||||
"common_unknown": "Nieznane",
|
||||
"common_yes": "Tak",
|
||||
"common_no": "Nie",
|
||||
"common_unknown_value": "Nieznane",
|
||||
"common_optional_notes": "opcjonalnie",
|
||||
"common_steps": "kroków",
|
||||
|
||||
"dashboard_title": "Dashboard",
|
||||
"dashboard_subtitle": "Przegląd zdrowia i pielęgnacji",
|
||||
"dashboard_latestSnapshot": "Ostatni stan skóry",
|
||||
"dashboard_recentRoutines": "Ostatnie rutyny",
|
||||
"dashboard_noSnapshots": "Brak wpisów o stanie skóry.",
|
||||
"dashboard_noRoutines": "Brak rutyno w ciągu ostatnich 2 tygodni.",
|
||||
"dashboard_title": "Dashboard",
|
||||
"dashboard_subtitle": "Przegląd zdrowia i pielęgnacji",
|
||||
"dashboard_latestSnapshot": "Ostatni stan skóry",
|
||||
"dashboard_recentRoutines": "Ostatnie rutyny",
|
||||
"dashboard_noSnapshots": "Brak wpisów o stanie skóry.",
|
||||
"dashboard_noRoutines": "Brak rutyn w ciągu ostatnich 2 tygodni.",
|
||||
|
||||
"products_title": "Produkty",
|
||||
"products_count": "{count} produktów",
|
||||
"products_addNew": "+ Dodaj produkt",
|
||||
"products_noProducts": "Nie znaleziono produktów.",
|
||||
"products_filterAll": "Wszystkie",
|
||||
"products_filterOwned": "Posiadane",
|
||||
"products_filterUnowned": "Nieposiadane",
|
||||
"products_colName": "Nazwa",
|
||||
"products_colBrand": "Marka",
|
||||
"products_colTargets": "Cele",
|
||||
"products_colTime": "Pora",
|
||||
"products_newTitle": "Nowy produkt",
|
||||
"products_backToList": "← Produkty",
|
||||
"products_createProduct": "Utwórz produkt",
|
||||
"products_saveChanges": "Zapisz zmiany",
|
||||
"products_deleteProduct": "Usuń produkt",
|
||||
"products_confirmDelete": "Usunąć ten produkt?",
|
||||
"products_noInventory": "Brak opakowań w magazynie.",
|
||||
"products_title": "Produkty",
|
||||
"products_count": [
|
||||
{
|
||||
"declarations": ["input count", "local countPlural = count: plural"],
|
||||
"selectors": ["countPlural"],
|
||||
"match": {
|
||||
"countPlural=one": "{count} produkt",
|
||||
"countPlural=few": "{count} produkty",
|
||||
"countPlural=many": "{count} produktów",
|
||||
"countPlural=*": "{count} produktów"
|
||||
}
|
||||
}
|
||||
],
|
||||
"products_addNew": "+ Dodaj produkt",
|
||||
"products_suggest": "Sugeruj",
|
||||
"products_suggestTitle": "Sugestie zakupowe",
|
||||
"products_suggestSubtitle": "Co warto kupić?",
|
||||
"products_suggestDescription": "Na podstawie Twojego stanu skóry i posiadanych produktów zasugeruję typy produktów, które mogłyby uzupełnić Twoją rutynę.",
|
||||
"products_suggestGenerating": "Analizuję...",
|
||||
"products_suggestBtn": "Generuj sugestie",
|
||||
"products_suggestResults": "Propozycje",
|
||||
"products_suggestTime": "Pora",
|
||||
"products_suggestFrequency": "Częstotliwość",
|
||||
"products_suggestRegenerate": "Wygeneruj ponownie",
|
||||
"products_suggestNoResults": "Brak propozycji.",
|
||||
"products_noProducts": "Nie znaleziono produktów.",
|
||||
"products_filterAll": "Wszystkie",
|
||||
"products_filterOwned": "Posiadane",
|
||||
"products_filterUnowned": "Nieposiadane",
|
||||
"products_colName": "Nazwa",
|
||||
"products_colBrand": "Marka",
|
||||
"products_colTargets": "Cele",
|
||||
"products_colTime": "Pora",
|
||||
"products_newTitle": "Nowy produkt",
|
||||
"products_backToList": "← Produkty",
|
||||
"products_createProduct": "Utwórz produkt",
|
||||
"products_saveChanges": "Zapisz zmiany",
|
||||
"products_deleteProduct": "Usuń produkt",
|
||||
"products_confirmDelete": "Usunąć ten produkt?",
|
||||
"products_noInventory": "Brak opakowań w magazynie.",
|
||||
|
||||
"inventory_title": "Opakowania ({count})",
|
||||
"inventory_addPackage": "+ Dodaj opakowanie",
|
||||
"inventory_packageAdded": "Opakowanie dodane.",
|
||||
"inventory_packageUpdated": "Opakowanie zaktualizowane.",
|
||||
"inventory_packageDeleted": "Opakowanie usunięte.",
|
||||
"inventory_alreadyOpened": "Już otwarte",
|
||||
"inventory_openedDate": "Data otwarcia",
|
||||
"inventory_finishedDate": "Data skończenia",
|
||||
"inventory_expiryDate": "Data ważności",
|
||||
"inventory_currentWeight": "Aktualna waga (g)",
|
||||
"inventory_lastWeighed": "Ostatnie ważenie",
|
||||
"inventory_notes": "Notatki",
|
||||
"inventory_badgeOpen": "Otwarte",
|
||||
"inventory_badgeSealed": "Zamknięte",
|
||||
"inventory_badgeFinished": "Skończone",
|
||||
"inventory_exp": "Wazność:",
|
||||
"inventory_opened": "Otwarto:",
|
||||
"inventory_finished": "Skończono:",
|
||||
"inventory_remaining": "g pozostało",
|
||||
"inventory_weighed": "Ważono:",
|
||||
"inventory_confirmDelete": "Usunąć to opakowanie?",
|
||||
"inventory_title": "Opakowania ({count})",
|
||||
"inventory_addPackage": "+ Dodaj opakowanie",
|
||||
"inventory_packageAdded": "Opakowanie dodane.",
|
||||
"inventory_packageUpdated": "Opakowanie zaktualizowane.",
|
||||
"inventory_packageDeleted": "Opakowanie usunięte.",
|
||||
"inventory_alreadyOpened": "Już otwarte",
|
||||
"inventory_openedDate": "Data otwarcia",
|
||||
"inventory_finishedDate": "Data skończenia",
|
||||
"inventory_expiryDate": "Data ważności",
|
||||
"inventory_currentWeight": "Aktualna waga (g)",
|
||||
"inventory_lastWeighed": "Ostatnie ważenie",
|
||||
"inventory_notes": "Notatki",
|
||||
"inventory_badgeOpen": "Otwarte",
|
||||
"inventory_badgeSealed": "Zamknięte",
|
||||
"inventory_badgeFinished": "Skończone",
|
||||
"inventory_exp": "Wazność:",
|
||||
"inventory_opened": "Otwarto:",
|
||||
"inventory_finished": "Skończono:",
|
||||
"inventory_remaining": "g pozostało",
|
||||
"inventory_weighed": "Ważono:",
|
||||
"inventory_confirmDelete": "Usunąć to opakowanie?",
|
||||
|
||||
"routines_title": "Rutyny",
|
||||
"routines_count": "{count} rutyno (ostatnie 30 dni)",
|
||||
"routines_suggestAI": "Zaproponuj rutynę AI",
|
||||
"routines_addNew": "+ Nowa rutyna",
|
||||
"routines_noRoutines": "Nie znaleziono rutyno.",
|
||||
"routines_newTitle": "Nowa rutyna",
|
||||
"routines_backToList": "← Rutyny",
|
||||
"routines_detailsTitle": "Szczegóły rutyny",
|
||||
"routines_date": "Data *",
|
||||
"routines_amOrPm": "AM lub PM *",
|
||||
"routines_notes": "Notatki",
|
||||
"routines_notesPlaceholder": "Opcjonalne notatki",
|
||||
"routines_createRoutine": "Utwórz rutynę",
|
||||
"routines_deleteRoutine": "Usuń rutynę",
|
||||
"routines_confirmDelete": "Usunąć tę rutynę?",
|
||||
"routines_steps": "Kroki ({count})",
|
||||
"routines_addStep": "+ Dodaj krok",
|
||||
"routines_addStepTitle": "Dodaj krok",
|
||||
"routines_product": "Produkt",
|
||||
"routines_selectProduct": "Wybierz produkt",
|
||||
"routines_dose": "Dawka",
|
||||
"routines_dosePlaceholder": "np. 2 pompki",
|
||||
"routines_region": "Okolica",
|
||||
"routines_regionPlaceholder": "np. twarz",
|
||||
"routines_addStepBtn": "Dodaj krok",
|
||||
"routines_unknownStep": "Nieznany krok",
|
||||
"routines_noSteps": "Brak kroków.",
|
||||
"routines_title": "Rutyny",
|
||||
"routines_count": [
|
||||
{
|
||||
"declarations": ["input count", "local countPlural = count: plural"],
|
||||
"selectors": ["countPlural"],
|
||||
"match": {
|
||||
"countPlural=one": "{count} rutyna (ostatnie 30 dni)",
|
||||
"countPlural=few": "{count} rutyny (ostatnie 30 dni)",
|
||||
"countPlural=many": "{count} rutyn (ostatnie 30 dni)",
|
||||
"countPlural=*": "{count} rutyn (ostatnie 30 dni)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"routines_suggestAI": "Zaproponuj rutynę AI",
|
||||
"routines_addNew": "+ Nowa rutyna",
|
||||
"routines_noRoutines": "Nie znaleziono rutyn.",
|
||||
"routines_newTitle": "Nowa rutyna",
|
||||
"routines_backToList": "← Rutyny",
|
||||
"routines_detailsTitle": "Szczegóły rutyny",
|
||||
"routines_date": "Data *",
|
||||
"routines_amOrPm": "AM lub PM *",
|
||||
"routines_notes": "Notatki",
|
||||
"routines_notesPlaceholder": "Opcjonalne notatki",
|
||||
"routines_createRoutine": "Utwórz rutynę",
|
||||
"routines_deleteRoutine": "Usuń rutynę",
|
||||
"routines_confirmDelete": "Usunąć tę rutynę?",
|
||||
"routines_steps": "Kroki ({count})",
|
||||
"routines_addStep": "+ Dodaj krok",
|
||||
"routines_addStepTitle": "Dodaj krok",
|
||||
"routines_product": "Produkt",
|
||||
"routines_selectProduct": "Wybierz produkt",
|
||||
"routines_dose": "Dawka",
|
||||
"routines_dosePlaceholder": "np. 2 pompki",
|
||||
"routines_region": "Okolica",
|
||||
"routines_regionPlaceholder": "np. twarz",
|
||||
"routines_addStepBtn": "Dodaj krok",
|
||||
"routines_unknownStep": "Nieznany krok",
|
||||
"routines_noSteps": "Brak kroków.",
|
||||
|
||||
"grooming_title": "Harmonogram pielęgnacji",
|
||||
"grooming_backToRoutines": "← Rutyny",
|
||||
"grooming_addEntry": "+ Dodaj wpis",
|
||||
"grooming_entryAdded": "Wpis dodany.",
|
||||
"grooming_entryUpdated": "Wpis zaktualizowany.",
|
||||
"grooming_entryDeleted": "Wpis usunięty.",
|
||||
"grooming_dayOfWeek": "Dzień tygodnia",
|
||||
"grooming_action": "Czynność",
|
||||
"grooming_notesOptional": "Notatki (opcjonalnie)",
|
||||
"grooming_notesPlaceholder": "np. co 2 tygodnie",
|
||||
"grooming_noEntries": "Brak wpisów. Kliknij \"+ Dodaj wpis\", aby zacząć.",
|
||||
"grooming_confirmDelete": "Usunąć ten wpis?",
|
||||
"grooming_actionShavingRazor": "Golenie maszynką",
|
||||
"grooming_actionShavingOneblade": "Golenie OneBlade",
|
||||
"grooming_actionDermarolling": "Dermarolling",
|
||||
"grooming_dayMonday": "Poniedziałek",
|
||||
"grooming_dayTuesday": "Wtorek",
|
||||
"grooming_dayWednesday": "Środa",
|
||||
"grooming_dayThursday": "Czwartek",
|
||||
"grooming_dayFriday": "Piątek",
|
||||
"grooming_daySaturday": "Sobota",
|
||||
"grooming_daySunday": "Niedziela",
|
||||
"grooming_title": "Harmonogram pielęgnacji",
|
||||
"grooming_backToRoutines": "← Rutyny",
|
||||
"grooming_addEntry": "+ Dodaj wpis",
|
||||
"grooming_entryAdded": "Wpis dodany.",
|
||||
"grooming_entryUpdated": "Wpis zaktualizowany.",
|
||||
"grooming_entryDeleted": "Wpis usunięty.",
|
||||
"grooming_dayOfWeek": "Dzień tygodnia",
|
||||
"grooming_action": "Czynność",
|
||||
"grooming_notesOptional": "Notatki (opcjonalnie)",
|
||||
"grooming_notesPlaceholder": "np. co 2 tygodnie",
|
||||
"grooming_noEntries": "Brak wpisów. Kliknij \"+ Dodaj wpis\", aby zacząć.",
|
||||
"grooming_confirmDelete": "Usunąć ten wpis?",
|
||||
"grooming_actionShavingRazor": "Golenie maszynką",
|
||||
"grooming_actionShavingOneblade": "Golenie OneBlade",
|
||||
"grooming_actionDermarolling": "Dermarolling",
|
||||
"grooming_dayMonday": "Poniedziałek",
|
||||
"grooming_dayTuesday": "Wtorek",
|
||||
"grooming_dayWednesday": "Środa",
|
||||
"grooming_dayThursday": "Czwartek",
|
||||
"grooming_dayFriday": "Piątek",
|
||||
"grooming_daySaturday": "Sobota",
|
||||
"grooming_daySunday": "Niedziela",
|
||||
|
||||
"suggest_title": "Propozycja rutyny AI",
|
||||
"suggest_backToRoutines": "← Rutyny",
|
||||
"suggest_singleTab": "Jedna rutyna",
|
||||
"suggest_batchTab": "Batch / Urlop",
|
||||
"suggest_singleParams": "Parametry",
|
||||
"suggest_date": "Data",
|
||||
"suggest_timeOfDay": "Pora dnia",
|
||||
"suggest_contextLabel": "Dodatkowy kontekst dla AI",
|
||||
"suggest_contextOptional": "(opcjonalny)",
|
||||
"suggest_contextPlaceholder": "np. wieczór imprezowy, skupiam się na nawilżeniu...",
|
||||
"suggest_generateBtn": "Generuj propozycję",
|
||||
"suggest_generating": "Generuję…",
|
||||
"suggest_proposalTitle": "Propozycja",
|
||||
"suggest_saveRoutine": "Zapisz rutynę",
|
||||
"suggest_saving": "Zapisuję…",
|
||||
"suggest_regenerate": "Wygeneruj ponownie",
|
||||
"suggest_batchRange": "Zakres dat",
|
||||
"suggest_fromDate": "Od",
|
||||
"suggest_toDate": "Do (max 14 dni)",
|
||||
"suggest_batchContextLabel": "Kontekst / cel wyjazdu",
|
||||
"suggest_batchContextPlaceholder": "np. słoneczna podróż do Włoch, aktywny urlop górski...",
|
||||
"suggest_generatePlan": "Generuj plan",
|
||||
"suggest_generatingPlan": "Generuję plan…",
|
||||
"suggest_planTitle": "Plan ({count} dni)",
|
||||
"suggest_saveAllRoutines": "Zapisz wszystkie rutyny",
|
||||
"suggest_amSteps": "kroków",
|
||||
"suggest_pmSteps": "kroków",
|
||||
"suggest_noAmSteps": "Brak kroków AM.",
|
||||
"suggest_noPmSteps": "Brak kroków PM.",
|
||||
"suggest_errorDefault": "Błąd podczas generowania.",
|
||||
"suggest_errorBatch": "Błąd podczas generowania planu.",
|
||||
"suggest_errorSave": "Błąd podczas zapisywania.",
|
||||
"suggest_amMorning": "AM (rano)",
|
||||
"suggest_pmEvening": "PM (wieczór)",
|
||||
"suggest_title": "Propozycja rutyny AI",
|
||||
"suggest_backToRoutines": "← Rutyny",
|
||||
"suggest_singleTab": "Jedna rutyna",
|
||||
"suggest_batchTab": "Batch / Urlop",
|
||||
"suggest_singleParams": "Parametry",
|
||||
"suggest_date": "Data",
|
||||
"suggest_timeOfDay": "Pora dnia",
|
||||
"suggest_contextLabel": "Dodatkowy kontekst dla AI",
|
||||
"suggest_contextOptional": "(opcjonalny)",
|
||||
"suggest_contextPlaceholder": "np. wieczór imprezowy, skupiam się na nawilżeniu...",
|
||||
"suggest_leavingHomeLabel": "Wychodzę dziś z domu",
|
||||
"suggest_leavingHomeHint": "Wpływa na wybór SPF — zaznaczone: SPF50+, odznaczone: SPF30.",
|
||||
"suggest_minoxidilToggleLabel": "Priorytet: gęstość brody/wąsów (minoksydyl)",
|
||||
"suggest_minoxidilToggleHint": "Po włączeniu AI jawnie uwzględni minoksydyl dla obszaru brody/wąsów, jeśli jest dostępny.",
|
||||
"suggest_generateBtn": "Generuj propozycję",
|
||||
"suggest_generating": "Generuję…",
|
||||
"suggest_proposalTitle": "Propozycja",
|
||||
"suggest_saveRoutine": "Zapisz rutynę",
|
||||
"suggest_saving": "Zapisuję…",
|
||||
"suggest_regenerate": "Wygeneruj ponownie",
|
||||
"suggest_batchRange": "Zakres dat",
|
||||
"suggest_fromDate": "Od",
|
||||
"suggest_toDate": "Do (max 14 dni)",
|
||||
"suggest_batchContextLabel": "Kontekst / cel wyjazdu",
|
||||
"suggest_batchContextPlaceholder": "np. słoneczna podróż do Włoch, aktywny urlop górski...",
|
||||
"suggest_generatePlan": "Generuj plan",
|
||||
"suggest_generatingPlan": "Generuję plan…",
|
||||
"suggest_planTitle": [
|
||||
{
|
||||
"declarations": ["input count", "local countPlural = count: plural"],
|
||||
"selectors": ["countPlural"],
|
||||
"match": {
|
||||
"countPlural=one": "Plan ({count} dzień)",
|
||||
"countPlural=few": "Plan ({count} dni)",
|
||||
"countPlural=many": "Plan ({count} dni)",
|
||||
"countPlural=*": "Plan ({count} dni)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"suggest_saveAllRoutines": "Zapisz wszystkie rutyny",
|
||||
"suggest_amSteps": "kroków",
|
||||
"suggest_pmSteps": "kroków",
|
||||
"suggest_noAmSteps": "Brak kroków AM.",
|
||||
"suggest_noPmSteps": "Brak kroków PM.",
|
||||
"suggest_errorDefault": "Błąd podczas generowania.",
|
||||
"suggest_errorBatch": "Błąd podczas generowania planu.",
|
||||
"suggest_errorSave": "Błąd podczas zapisywania.",
|
||||
"suggest_amMorning": "AM (rano)",
|
||||
"suggest_pmEvening": "PM (wieczór)",
|
||||
"suggest_summaryPrimaryGoal": "Główny cel",
|
||||
"suggest_summaryConfidence": "Pewność",
|
||||
"suggest_summaryConstraints": "Ograniczenia",
|
||||
"suggest_stepOptionalBadge": "opcjonalny",
|
||||
|
||||
"medications_title": "Leki",
|
||||
"medications_count": "{count} wpisów",
|
||||
"medications_addNew": "+ Dodaj lek",
|
||||
"medications_newTitle": "Nowy lek",
|
||||
"medications_kind": "Rodzaj",
|
||||
"medications_productName": "Nazwa produktu *",
|
||||
"medications_productNamePlaceholder": "np. Witamina D3",
|
||||
"medications_activeSubstance": "Substancja czynna",
|
||||
"medications_activeSubstancePlaceholder": "np. cholekalcyferol",
|
||||
"medications_notes": "Notatki",
|
||||
"medications_added": "Lek dodany.",
|
||||
"medications_usages": "{count} użyć",
|
||||
"medications_noMedications": "Brak leków.",
|
||||
"medications_kindPrescription": "Na receptę",
|
||||
"medications_kindOtc": "OTC (bez recepty)",
|
||||
"medications_kindSupplement": "Suplement",
|
||||
"medications_kindHerbal": "Zioła",
|
||||
"medications_kindOther": "Inne",
|
||||
"medications_title": "Leki",
|
||||
"medications_count": [
|
||||
{
|
||||
"declarations": ["input count", "local countPlural = count: plural"],
|
||||
"selectors": ["countPlural"],
|
||||
"match": {
|
||||
"countPlural=one": "{count} wpis",
|
||||
"countPlural=few": "{count} wpisy",
|
||||
"countPlural=many": "{count} wpisów",
|
||||
"countPlural=*": "{count} wpisów"
|
||||
}
|
||||
}
|
||||
],
|
||||
"medications_addNew": "+ Dodaj lek",
|
||||
"medications_newTitle": "Nowy lek",
|
||||
"medications_kind": "Rodzaj",
|
||||
"medications_productName": "Nazwa produktu *",
|
||||
"medications_productNamePlaceholder": "np. Witamina D3",
|
||||
"medications_activeSubstance": "Substancja czynna",
|
||||
"medications_activeSubstancePlaceholder": "np. cholekalcyferol",
|
||||
"medications_notes": "Notatki",
|
||||
"medications_added": "Lek dodany.",
|
||||
"medications_usages": [
|
||||
{
|
||||
"declarations": ["input count", "local countPlural = count: plural"],
|
||||
"selectors": ["countPlural"],
|
||||
"match": {
|
||||
"countPlural=one": "{count} użycie",
|
||||
"countPlural=few": "{count} użycia",
|
||||
"countPlural=many": "{count} użyć",
|
||||
"countPlural=*": "{count} użyć"
|
||||
}
|
||||
}
|
||||
],
|
||||
"medications_noMedications": "Brak leków.",
|
||||
"medications_kindPrescription": "Na receptę",
|
||||
"medications_kindOtc": "OTC (bez recepty)",
|
||||
"medications_kindSupplement": "Suplement",
|
||||
"medications_kindHerbal": "Zioła",
|
||||
"medications_kindOther": "Inne",
|
||||
|
||||
"labResults_title": "Wyniki badań",
|
||||
"labResults_count": "{count} wyników",
|
||||
"labResults_addNew": "+ Dodaj wynik",
|
||||
"labResults_newTitle": "Nowy wynik badania",
|
||||
"labResults_flagFilter": "Flaga:",
|
||||
"labResults_flagAll": "Wszystkie",
|
||||
"labResults_flagNone": "Brak",
|
||||
"labResults_date": "Data *",
|
||||
"labResults_loincCode": "Kod LOINC *",
|
||||
"labResults_testName": "Nazwa badania",
|
||||
"labResults_testNamePlaceholder": "np. Hemoglobina",
|
||||
"labResults_lab": "Laboratorium",
|
||||
"labResults_labPlaceholder": "np. LabCorp",
|
||||
"labResults_value": "Wartość",
|
||||
"labResults_unit": "Jednostka",
|
||||
"labResults_unitPlaceholder": "np. g/dL",
|
||||
"labResults_flag": "Flaga",
|
||||
"labResults_added": "Wynik dodany.",
|
||||
"labResults_colDate": "Data",
|
||||
"labResults_colTest": "Badanie",
|
||||
"labResults_colLoinc": "LOINC",
|
||||
"labResults_colValue": "Wartość",
|
||||
"labResults_colFlag": "Flaga",
|
||||
"labResults_colLab": "Lab",
|
||||
"labResults_noResults": "Nie znaleziono wyników badań.",
|
||||
"labResults_title": "Wyniki badań",
|
||||
"labResults_count": [
|
||||
{
|
||||
"declarations": ["input count", "local countPlural = count: plural"],
|
||||
"selectors": ["countPlural"],
|
||||
"match": {
|
||||
"countPlural=one": "{count} wynik",
|
||||
"countPlural=few": "{count} wyniki",
|
||||
"countPlural=many": "{count} wyników",
|
||||
"countPlural=*": "{count} wyników"
|
||||
}
|
||||
}
|
||||
],
|
||||
"labResults_addNew": "+ Dodaj wynik",
|
||||
"labResults_newTitle": "Nowy wynik badania",
|
||||
"labResults_flagFilter": "Flaga:",
|
||||
"labResults_flagAll": "Wszystkie",
|
||||
"labResults_flagNone": "Brak",
|
||||
"labResults_date": "Data *",
|
||||
"labResults_loincCode": "Kod LOINC *",
|
||||
"labResults_testName": "Nazwa badania",
|
||||
"labResults_testNamePlaceholder": "np. Hemoglobina",
|
||||
"labResults_lab": "Laboratorium",
|
||||
"labResults_labPlaceholder": "np. LabCorp",
|
||||
"labResults_value": "Wartość",
|
||||
"labResults_unit": "Jednostka",
|
||||
"labResults_unitPlaceholder": "np. g/dL",
|
||||
"labResults_flag": "Flaga",
|
||||
"labResults_added": "Wynik dodany.",
|
||||
"labResults_colDate": "Data",
|
||||
"labResults_colTest": "Badanie",
|
||||
"labResults_colLoinc": "LOINC",
|
||||
"labResults_colValue": "Wartość",
|
||||
"labResults_colFlag": "Flaga",
|
||||
"labResults_colLab": "Lab",
|
||||
"labResults_noResults": "Nie znaleziono wyników badań.",
|
||||
|
||||
"skin_title": "Stan skóry",
|
||||
"skin_count": "{count} wpisów",
|
||||
"skin_addNew": "+ Dodaj wpis",
|
||||
"skin_aiAnalysisTitle": "Analiza AI ze zdjęć",
|
||||
"skin_aiUploadText": "Prześlij 1–3 zdjęcia skóry. AI wypełni pola formularza poniżej.",
|
||||
"skin_analyzePhotos": "Analizuj zdjęcia",
|
||||
"skin_analyzing": "Analizuję…",
|
||||
"skin_newSnapshotTitle": "Nowy wpis",
|
||||
"skin_date": "Data *",
|
||||
"skin_overallState": "Ogólny stan",
|
||||
"skin_texture": "Tekstura",
|
||||
"skin_skinType": "Typ skóry",
|
||||
"skin_barrierState": "Stan bariery",
|
||||
"skin_hydration": "Nawilżenie (1–5)",
|
||||
"skin_sensitivity": "Wrażliwość (1–5)",
|
||||
"skin_sebumTzone": "Sebum T-zone (1–5)",
|
||||
"skin_sebumCheeks": "Sebum policzki (1–5)",
|
||||
"skin_activeConcerns": "Aktywne problemy (przecinek)",
|
||||
"skin_activeConcernsPlaceholder": "trądzik, zaczerwienienie, odwodnienie",
|
||||
"skin_notes": "Notatki",
|
||||
"skin_addSnapshot": "Dodaj wpis",
|
||||
"skin_snapshotAdded": "Wpis dodany.",
|
||||
"skin_snapshotUpdated": "Wpis zaktualizowany.",
|
||||
"skin_snapshotDeleted": "Wpis usunięty.",
|
||||
"skin_noSnapshots": "Brak wpisów o stanie skóry.",
|
||||
"skin_hydrationLabel": "Nawilżenie",
|
||||
"skin_sensitivityLabel": "Wrażliwość",
|
||||
"skin_barrierLabel": "Bariera",
|
||||
"skin_stateExcellent": "doskonały",
|
||||
"skin_stateGood": "dobry",
|
||||
"skin_stateFair": "przeciętny",
|
||||
"skin_statePoor": "zły",
|
||||
"skin_textureSmooth": "gładka",
|
||||
"skin_textureRough": "szorstka",
|
||||
"skin_textureFlaky": "łuszcząca się",
|
||||
"skin_textureBumpy": "nierówna",
|
||||
"skin_barrierIntact": "nienaruszona",
|
||||
"skin_barrierMildly": "lekko naruszona",
|
||||
"skin_barrierCompromised": "naruszona",
|
||||
"skin_typeDry": "sucha",
|
||||
"skin_typeOily": "tłusta",
|
||||
"skin_typeCombination": "mieszana",
|
||||
"skin_typeSensitive": "wrażliwa",
|
||||
"skin_typeNormal": "normalna",
|
||||
"skin_typeAcneProne": "trądzikowa",
|
||||
"skin_title": "Stan skóry",
|
||||
"skin_count": [
|
||||
{
|
||||
"declarations": ["input count", "local countPlural = count: plural"],
|
||||
"selectors": ["countPlural"],
|
||||
"match": {
|
||||
"countPlural=one": "{count} wpis",
|
||||
"countPlural=few": "{count} wpisy",
|
||||
"countPlural=many": "{count} wpisów",
|
||||
"countPlural=*": "{count} wpisów"
|
||||
}
|
||||
}
|
||||
],
|
||||
"skin_addNew": "+ Dodaj wpis",
|
||||
"skin_aiAnalysisTitle": "Analiza AI ze zdjęć",
|
||||
"skin_aiUploadText": "Prześlij 1–3 zdjęcia skóry. AI wypełni pola formularza poniżej.",
|
||||
"skin_analyzePhotos": "Analizuj zdjęcia",
|
||||
"skin_analyzing": "Analizuję…",
|
||||
"skin_newSnapshotTitle": "Nowy wpis",
|
||||
"skin_date": "Data *",
|
||||
"skin_overallState": "Ogólny stan",
|
||||
"skin_texture": "Tekstura",
|
||||
"skin_skinType": "Typ skóry",
|
||||
"skin_barrierState": "Stan bariery",
|
||||
"skin_hydration": "Nawilżenie (1–5)",
|
||||
"skin_sensitivity": "Wrażliwość (1–5)",
|
||||
"skin_sebumTzone": "Sebum T-zone (1–5)",
|
||||
"skin_sebumCheeks": "Sebum policzki (1–5)",
|
||||
"skin_activeConcerns": "Aktywne problemy (przecinek)",
|
||||
"skin_activeConcernsPlaceholder": "trądzik, zaczerwienienie, odwodnienie",
|
||||
"skin_priorities": "Priorytety (przecinek)",
|
||||
"skin_prioritiesPlaceholder": "wzmocnić barierę, redukować zaczerwienienie",
|
||||
"skin_prioritiesLabel": "Priorytety",
|
||||
"skin_notes": "Notatki",
|
||||
"skin_addSnapshot": "Dodaj wpis",
|
||||
"skin_snapshotAdded": "Wpis dodany.",
|
||||
"skin_snapshotUpdated": "Wpis zaktualizowany.",
|
||||
"skin_snapshotDeleted": "Wpis usunięty.",
|
||||
"skin_noSnapshots": "Brak wpisów o stanie skóry.",
|
||||
"skin_hydrationLabel": "Nawilżenie",
|
||||
"skin_sensitivityLabel": "Wrażliwość",
|
||||
"skin_barrierLabel": "Bariera",
|
||||
"skin_stateExcellent": "doskonały",
|
||||
"skin_stateGood": "dobry",
|
||||
"skin_stateFair": "przeciętny",
|
||||
"skin_statePoor": "zły",
|
||||
"skin_textureSmooth": "gładka",
|
||||
"skin_textureRough": "szorstka",
|
||||
"skin_textureFlaky": "łuszcząca się",
|
||||
"skin_textureBumpy": "nierówna",
|
||||
"skin_barrierIntact": "nienaruszona",
|
||||
"skin_barrierMildly": "lekko naruszona",
|
||||
"skin_barrierCompromised": "naruszona",
|
||||
"skin_typeDry": "sucha",
|
||||
"skin_typeOily": "tłusta",
|
||||
"skin_typeCombination": "mieszana",
|
||||
"skin_typeSensitive": "wrażliwa",
|
||||
"skin_typeNormal": "normalna",
|
||||
"skin_typeAcneProne": "trądzikowa",
|
||||
|
||||
"productForm_aiPrefill": "Uzupełnienie AI",
|
||||
"productForm_aiPrefillText": "Wklej opis produktu ze strony, listę składników lub inny tekst. AI uzupełni dostępne pola — możesz je przejrzeć i poprawić przed zapisem.",
|
||||
"productForm_pasteText": "Wklej tutaj opis produktu, składniki INCI...",
|
||||
"productForm_parseWithAI": "Uzupełnij pola (AI)",
|
||||
"productForm_parsing": "Przetwarzam…",
|
||||
"productForm_basicInfo": "Informacje podstawowe",
|
||||
"productForm_name": "Nazwa *",
|
||||
"productForm_namePlaceholder": "np. Hydro Boost Water Gel",
|
||||
"productForm_brand": "Marka *",
|
||||
"productForm_brandPlaceholder": "np. Neutrogena",
|
||||
"productForm_lineName": "Linia / seria",
|
||||
"productForm_lineNamePlaceholder": "np. Hydro Boost",
|
||||
"productForm_url": "URL",
|
||||
"productForm_sku": "SKU",
|
||||
"productForm_skuPlaceholder": "np. NTR-HB-50",
|
||||
"productForm_barcode": "Kod kreskowy / EAN",
|
||||
"productForm_barcodePlaceholder": "np. 3614273258975",
|
||||
"productForm_classification": "Klasyfikacja",
|
||||
"productForm_category": "Kategoria *",
|
||||
"productForm_selectCategory": "Wybierz kategorię",
|
||||
"productForm_time": "Pora *",
|
||||
"productForm_timeOptions": "AM / PM / Oba",
|
||||
"productForm_timeBoth": "Oba",
|
||||
"productForm_leaveOn": "Leave-on *",
|
||||
"productForm_leaveOnYes": "Tak (leave-on)",
|
||||
"productForm_leaveOnNo": "Nie (rinse-off)",
|
||||
"productForm_texture": "Tekstura",
|
||||
"productForm_selectTexture": "Wybierz teksturę",
|
||||
"productForm_absorptionSpeed": "Szybkość wchłaniania",
|
||||
"productForm_selectSpeed": "Wybierz szybkość",
|
||||
"productForm_skinProfile": "Profil skóry",
|
||||
"productForm_recommendedFor": "Polecane dla typów skóry",
|
||||
"productForm_targetConcerns": "Problemy docelowe",
|
||||
"productForm_contraindications": "Przeciwwskazania (jedno na linię)",
|
||||
"productForm_contraindicationsPlaceholder": "np. aktywna rosacea",
|
||||
"productForm_ingredients": "Składniki",
|
||||
"productForm_inciList": "Lista INCI (jeden składnik na linię)",
|
||||
"productForm_activeIngredients": "Składniki aktywne",
|
||||
"productForm_addActive": "+ Dodaj aktywny",
|
||||
"productForm_noActives": "Brak składników aktywnych.",
|
||||
"productForm_activeName": "Nazwa",
|
||||
"productForm_activePercent": "%",
|
||||
"productForm_activeStrength": "Siła",
|
||||
"productForm_activeIrritation": "Podrażnienie",
|
||||
"productForm_activeFunctions": "Funkcje",
|
||||
"productForm_effectProfile": "Profil działania (0–5)",
|
||||
"productForm_interactions": "Interakcje",
|
||||
"productForm_synergizesWith": "Synergizuje z (jedno na linię)",
|
||||
"productForm_incompatibleWith": "Niekompatybilny z",
|
||||
"productForm_addIncompatibility": "+ Dodaj niekompatybilność",
|
||||
"productForm_noIncompatibilities": "Brak niekompatybilności.",
|
||||
"productForm_incompTarget": "Składnik docelowy",
|
||||
"productForm_incompScope": "Zakres",
|
||||
"productForm_incompReason": "Powód (opcjonalny)",
|
||||
"productForm_incompReasonPlaceholder": "np. zmniejsza skuteczność",
|
||||
"productForm_incompScopeSelect": "Wybierz…",
|
||||
"productForm_contextRules": "Reguły kontekstu",
|
||||
"productForm_ctxAfterShaving": "Bezpieczny po goleniu",
|
||||
"productForm_ctxAfterAcids": "Bezpieczny po kwasach",
|
||||
"productForm_ctxAfterRetinoids": "Bezpieczny po retinoidach",
|
||||
"productForm_ctxCompromisedBarrier": "Bezpieczny przy naruszonej barierze",
|
||||
"productForm_ctxLowUvOnly": "Tylko przy niskim UV (wieczór/zakrycie)",
|
||||
"productForm_productDetails": "Szczegóły produktu",
|
||||
"productForm_priceTier": "Przedział cenowy",
|
||||
"productForm_selectTier": "Wybierz przedział",
|
||||
"productForm_sizeMl": "Rozmiar (ml)",
|
||||
"productForm_fullWeightG": "Waga pełna (g)",
|
||||
"productForm_emptyWeightG": "Waga pustego (g)",
|
||||
"productForm_paoMonths": "PAO (miesiące)",
|
||||
"productForm_phMin": "pH min",
|
||||
"productForm_phMax": "pH max",
|
||||
"productForm_usageNotes": "Notatki o stosowaniu",
|
||||
"productForm_usageNotesPlaceholder": "np. Nakładaj na wilgotną skórę, unikaj okolic oczu",
|
||||
"productForm_safetyFlags": "Flagi bezpieczeństwa",
|
||||
"productForm_fragranceFree": "Bez zapachów",
|
||||
"productForm_essentialOilsFree": "Bez olejków eterycznych",
|
||||
"productForm_alcoholDenatFree": "Bez alkoholu denat.",
|
||||
"productForm_pregnancySafe": "Bezpieczny w ciąży",
|
||||
"productForm_usageConstraints": "Ograniczenia stosowania",
|
||||
"productForm_minIntervalHours": "Min. przerwa (godziny)",
|
||||
"productForm_maxFrequencyPerWeek": "Max użyć na tydzień",
|
||||
"productForm_isMedication": "To lek",
|
||||
"productForm_isTool": "To narzędzie (np. dermaroller)",
|
||||
"productForm_needleLengthMm": "Długość igły (mm, tylko narzędzia)",
|
||||
"productForm_personalNotes": "Notatki osobiste",
|
||||
"productForm_repurchaseIntent": "Zamiar ponownego zakupu",
|
||||
"productForm_toleranceNotes": "Notatki o tolerancji",
|
||||
"productForm_toleranceNotesPlaceholder": "np. Lekkie pieczenie, ustępuje po 2 tygodniach",
|
||||
"productForm_aiPrefill": "Uzupełnienie AI",
|
||||
"productForm_aiPrefillText": "Wklej opis produktu ze strony, listę składników lub inny tekst. AI uzupełni dostępne pola — możesz je przejrzeć i poprawić przed zapisem.",
|
||||
"productForm_pasteText": "Wklej tutaj opis produktu, składniki INCI...",
|
||||
"productForm_parseWithAI": "Uzupełnij pola (AI)",
|
||||
"productForm_parsing": "Przetwarzam…",
|
||||
"productForm_basicInfo": "Informacje podstawowe",
|
||||
"productForm_name": "Nazwa *",
|
||||
"productForm_namePlaceholder": "np. Hydro Boost Water Gel",
|
||||
"productForm_brand": "Marka *",
|
||||
"productForm_brandPlaceholder": "np. Neutrogena",
|
||||
"productForm_lineName": "Linia / seria",
|
||||
"productForm_lineNamePlaceholder": "np. Hydro Boost",
|
||||
"productForm_url": "URL",
|
||||
"productForm_sku": "SKU",
|
||||
"productForm_skuPlaceholder": "np. NTR-HB-50",
|
||||
"productForm_barcode": "Kod kreskowy / EAN",
|
||||
"productForm_barcodePlaceholder": "np. 3614273258975",
|
||||
"productForm_classification": "Klasyfikacja",
|
||||
"productForm_category": "Kategoria *",
|
||||
"productForm_selectCategory": "Wybierz kategorię",
|
||||
"productForm_time": "Pora *",
|
||||
"productForm_timeOptions": "AM / PM / Oba",
|
||||
"productForm_timeBoth": "Oba",
|
||||
"productForm_leaveOn": "Leave-on *",
|
||||
"productForm_leaveOnYes": "Tak (leave-on)",
|
||||
"productForm_leaveOnNo": "Nie (rinse-off)",
|
||||
"productForm_texture": "Tekstura",
|
||||
"productForm_selectTexture": "Wybierz teksturę",
|
||||
"productForm_absorptionSpeed": "Szybkość wchłaniania",
|
||||
"productForm_selectSpeed": "Wybierz szybkość",
|
||||
"productForm_skinProfile": "Profil skóry",
|
||||
"productForm_recommendedFor": "Polecane dla typów skóry",
|
||||
"productForm_targetConcerns": "Problemy docelowe",
|
||||
"productForm_contraindications": "Przeciwwskazania (jedno na linię)",
|
||||
"productForm_contraindicationsPlaceholder": "np. aktywna rosacea",
|
||||
"productForm_ingredients": "Składniki",
|
||||
"productForm_inciList": "Lista INCI (jeden składnik na linię)",
|
||||
"productForm_activeIngredients": "Składniki aktywne",
|
||||
"productForm_addActive": "+ Dodaj aktywny",
|
||||
"productForm_noActives": "Brak składników aktywnych.",
|
||||
"productForm_activeName": "Nazwa",
|
||||
"productForm_activePercent": "%",
|
||||
"productForm_activeStrength": "Siła",
|
||||
"productForm_activeIrritation": "Podrażnienie",
|
||||
"productForm_activeFunctions": "Funkcje",
|
||||
"productForm_effectProfile": "Profil działania (0–5)",
|
||||
"productForm_interactions": "Interakcje",
|
||||
"productForm_synergizesWith": "Synergizuje z (jedno na linię)",
|
||||
"productForm_incompatibleWith": "Niekompatybilny z",
|
||||
"productForm_addIncompatibility": "+ Dodaj niekompatybilność",
|
||||
"productForm_noIncompatibilities": "Brak niekompatybilności.",
|
||||
"productForm_incompTarget": "Składnik docelowy",
|
||||
"productForm_incompScope": "Zakres",
|
||||
"productForm_incompReason": "Powód (opcjonalny)",
|
||||
"productForm_incompReasonPlaceholder": "np. zmniejsza skuteczność",
|
||||
"productForm_incompScopeSelect": "Wybierz…",
|
||||
"productForm_contextRules": "Reguły kontekstu",
|
||||
"productForm_ctxAfterShaving": "Bezpieczny po goleniu",
|
||||
"productForm_ctxAfterAcids": "Bezpieczny po kwasach",
|
||||
"productForm_ctxAfterRetinoids": "Bezpieczny po retinoidach",
|
||||
"productForm_ctxCompromisedBarrier": "Bezpieczny przy naruszonej barierze",
|
||||
"productForm_ctxLowUvOnly": "Tylko przy niskim UV (wieczór/zakrycie)",
|
||||
"productForm_productDetails": "Szczegóły produktu",
|
||||
"productForm_priceTier": "Przedział cenowy",
|
||||
"productForm_selectTier": "Wybierz przedział",
|
||||
"productForm_sizeMl": "Rozmiar (ml)",
|
||||
"productForm_fullWeightG": "Waga pełna (g)",
|
||||
"productForm_emptyWeightG": "Waga pustego (g)",
|
||||
"productForm_paoMonths": "PAO (miesiące)",
|
||||
"productForm_phMin": "pH min",
|
||||
"productForm_phMax": "pH max",
|
||||
"productForm_usageNotes": "Notatki o stosowaniu",
|
||||
"productForm_usageNotesPlaceholder": "np. Nakładaj na wilgotną skórę, unikaj okolic oczu",
|
||||
"productForm_safetyFlags": "Flagi bezpieczeństwa",
|
||||
"productForm_fragranceFree": "Bez zapachów",
|
||||
"productForm_essentialOilsFree": "Bez olejków eterycznych",
|
||||
"productForm_alcoholDenatFree": "Bez alkoholu denat.",
|
||||
"productForm_pregnancySafe": "Bezpieczny w ciąży",
|
||||
"productForm_usageConstraints": "Ograniczenia stosowania",
|
||||
"productForm_minIntervalHours": "Min. przerwa (godziny)",
|
||||
"productForm_maxFrequencyPerWeek": "Max użyć na tydzień",
|
||||
"productForm_isMedication": "To lek",
|
||||
"productForm_isTool": "To narzędzie (np. dermaroller)",
|
||||
"productForm_needleLengthMm": "Długość igły (mm, tylko narzędzia)",
|
||||
"productForm_personalNotes": "Notatki osobiste",
|
||||
"productForm_repurchaseIntent": "Zamiar ponownego zakupu",
|
||||
"productForm_toleranceNotes": "Notatki o tolerancji",
|
||||
"productForm_toleranceNotesPlaceholder": "np. Lekkie pieczenie, ustępuje po 2 tygodniach",
|
||||
|
||||
"productForm_categoryCleanser": "Żel/pianka do mycia",
|
||||
"productForm_categoryToner": "Tonik",
|
||||
"productForm_categoryEssence": "Esencja",
|
||||
"productForm_categorySerum": "Serum",
|
||||
"productForm_categoryMoisturizer": "Krem",
|
||||
"productForm_categorySpf": "SPF",
|
||||
"productForm_categoryMask": "Maska",
|
||||
"productForm_categoryExfoliant": "Peeling",
|
||||
"productForm_categoryHairTreatment": "Pielęgnacja włosów",
|
||||
"productForm_categoryTool": "Narzędzie",
|
||||
"productForm_categorySpotTreatment": "Punkt leczenia",
|
||||
"productForm_categoryOil": "Olejek",
|
||||
"productForm_categoryCleanser": "Żel/pianka do mycia",
|
||||
"productForm_categoryToner": "Tonik",
|
||||
"productForm_categoryEssence": "Esencja",
|
||||
"productForm_categorySerum": "Serum",
|
||||
"productForm_categoryMoisturizer": "Krem",
|
||||
"productForm_categorySpf": "SPF",
|
||||
"productForm_categoryMask": "Maska",
|
||||
"productForm_categoryExfoliant": "Peeling",
|
||||
"productForm_categoryHairTreatment": "Pielęgnacja włosów",
|
||||
"productForm_categoryTool": "Narzędzie",
|
||||
"productForm_categorySpotTreatment": "Punkt leczenia",
|
||||
"productForm_categoryOil": "Olejek",
|
||||
|
||||
"productForm_textureWatery": "Wodnista",
|
||||
"productForm_textureGel": "Żel",
|
||||
"productForm_textureEmulsion": "Emulsja",
|
||||
"productForm_textureCream": "Krem",
|
||||
"productForm_textureOil": "Olejek",
|
||||
"productForm_textureBalm": "Balsam",
|
||||
"productForm_textureFoam": "Pianka",
|
||||
"productForm_textureFluid": "Fluid",
|
||||
"productForm_textureWatery": "Wodnista",
|
||||
"productForm_textureGel": "Żel",
|
||||
"productForm_textureEmulsion": "Emulsja",
|
||||
"productForm_textureCream": "Krem",
|
||||
"productForm_textureOil": "Olejek",
|
||||
"productForm_textureBalm": "Balsam",
|
||||
"productForm_textureFoam": "Pianka",
|
||||
"productForm_textureFluid": "Fluid",
|
||||
|
||||
"productForm_absorptionVeryFast": "Bardzo szybkie",
|
||||
"productForm_absorptionFast": "Szybkie",
|
||||
"productForm_absorptionModerate": "Umiarkowane",
|
||||
"productForm_absorptionSlow": "Wolne",
|
||||
"productForm_absorptionVerySlow": "Bardzo wolne",
|
||||
"productForm_absorptionVeryFast": "Bardzo szybkie",
|
||||
"productForm_absorptionFast": "Szybkie",
|
||||
"productForm_absorptionModerate": "Umiarkowane",
|
||||
"productForm_absorptionSlow": "Wolne",
|
||||
"productForm_absorptionVerySlow": "Bardzo wolne",
|
||||
|
||||
"productForm_priceBudget": "Budżetowy",
|
||||
"productForm_priceMid": "Średni",
|
||||
"productForm_pricePremium": "Premium",
|
||||
"productForm_priceLuxury": "Luksusowy",
|
||||
"productForm_priceBudget": "Budżetowy",
|
||||
"productForm_priceMid": "Średni",
|
||||
"productForm_pricePremium": "Premium",
|
||||
"productForm_priceLuxury": "Luksusowy",
|
||||
|
||||
"productForm_skinTypeDry": "sucha",
|
||||
"productForm_skinTypeOily": "tłusta",
|
||||
"productForm_skinTypeCombination": "mieszana",
|
||||
"productForm_skinTypeSensitive": "wrażliwa",
|
||||
"productForm_skinTypeNormal": "normalna",
|
||||
"productForm_skinTypeAcneProne": "trądzikowa",
|
||||
"productForm_skinTypeDry": "sucha",
|
||||
"productForm_skinTypeOily": "tłusta",
|
||||
"productForm_skinTypeCombination": "mieszana",
|
||||
"productForm_skinTypeSensitive": "wrażliwa",
|
||||
"productForm_skinTypeNormal": "normalna",
|
||||
"productForm_skinTypeAcneProne": "trądzikowa",
|
||||
|
||||
"productForm_concernAcne": "trądzik",
|
||||
"productForm_concernRosacea": "rosacea",
|
||||
"productForm_concernHyperpigmentation": "przebarwienia",
|
||||
"productForm_concernAging": "starzenie",
|
||||
"productForm_concernDehydration": "odwodnienie",
|
||||
"productForm_concernRedness": "zaczerwienienie",
|
||||
"productForm_concernDamagedBarrier": "naruszona bariera",
|
||||
"productForm_concernPoreVisibility": "widoczność porów",
|
||||
"productForm_concernUnevenTexture": "nierówna tekstura",
|
||||
"productForm_concernHairGrowth": "wzrost włosów",
|
||||
"productForm_concernSebumExcess": "nadmiar sebum",
|
||||
"productForm_concernAcne": "trądzik",
|
||||
"productForm_concernRosacea": "rosacea",
|
||||
"productForm_concernHyperpigmentation": "przebarwienia",
|
||||
"productForm_concernAging": "starzenie",
|
||||
"productForm_concernDehydration": "odwodnienie",
|
||||
"productForm_concernRedness": "zaczerwienienie",
|
||||
"productForm_concernDamagedBarrier": "naruszona bariera",
|
||||
"productForm_concernPoreVisibility": "widoczność porów",
|
||||
"productForm_concernUnevenTexture": "nierówna tekstura",
|
||||
"productForm_concernHairGrowth": "wzrost włosów",
|
||||
"productForm_concernSebumExcess": "nadmiar sebum",
|
||||
|
||||
"productForm_fnHumectant": "humektant",
|
||||
"productForm_fnEmollient": "emolient",
|
||||
"productForm_fnOcclusive": "okluzja",
|
||||
"productForm_fnExfoliantAha": "peeling AHA",
|
||||
"productForm_fnExfoliantBha": "peeling BHA",
|
||||
"productForm_fnExfoliantPha": "peeling PHA",
|
||||
"productForm_fnRetinoid": "retinoid",
|
||||
"productForm_fnAntioxidant": "antyoksydant",
|
||||
"productForm_fnSoothing": "łagodzący",
|
||||
"productForm_fnBarrierSupport": "wsparcie bariery",
|
||||
"productForm_fnBrightening": "rozjaśniający",
|
||||
"productForm_fnAntiAcne": "przeciwtrądzikowy",
|
||||
"productForm_fnCeramide": "ceramid",
|
||||
"productForm_fnNiacinamide": "niacynamid",
|
||||
"productForm_fnSunscreen": "filtr UV",
|
||||
"productForm_fnPeptide": "peptyd",
|
||||
"productForm_fnHairGrowth": "stymulator wzrostu włosów",
|
||||
"productForm_fnPrebiotic": "prebiotyk",
|
||||
"productForm_fnVitaminC": "witamina C",
|
||||
"productForm_fnAntiAging": "przeciwstarzeniowy",
|
||||
"productForm_fnHumectant": "humektant",
|
||||
"productForm_fnEmollient": "emolient",
|
||||
"productForm_fnOcclusive": "okluzja",
|
||||
"productForm_fnExfoliantAha": "peeling AHA",
|
||||
"productForm_fnExfoliantBha": "peeling BHA",
|
||||
"productForm_fnExfoliantPha": "peeling PHA",
|
||||
"productForm_fnRetinoid": "retinoid",
|
||||
"productForm_fnAntioxidant": "antyoksydant",
|
||||
"productForm_fnSoothing": "łagodzący",
|
||||
"productForm_fnBarrierSupport": "wsparcie bariery",
|
||||
"productForm_fnBrightening": "rozjaśniający",
|
||||
"productForm_fnAntiAcne": "przeciwtrądzikowy",
|
||||
"productForm_fnCeramide": "ceramid",
|
||||
"productForm_fnNiacinamide": "niacynamid",
|
||||
"productForm_fnSunscreen": "filtr UV",
|
||||
"productForm_fnPeptide": "peptyd",
|
||||
"productForm_fnHairGrowth": "stymulator wzrostu włosów",
|
||||
"productForm_fnPrebiotic": "prebiotyk",
|
||||
"productForm_fnVitaminC": "witamina C",
|
||||
"productForm_fnAntiAging": "przeciwstarzeniowy",
|
||||
|
||||
"productForm_scopeSameStep": "ten sam krok",
|
||||
"productForm_scopeSameDay": "ten sam dzień",
|
||||
"productForm_scopeSamePeriod": "ten sam okres",
|
||||
"productForm_scopeSameStep": "ten sam krok",
|
||||
"productForm_scopeSameDay": "ten sam dzień",
|
||||
"productForm_scopeSamePeriod": "ten sam okres",
|
||||
|
||||
"productForm_strengthLow": "1 Niskie",
|
||||
"productForm_strengthMedium": "2 Średnie",
|
||||
"productForm_strengthHigh": "3 Wysokie",
|
||||
"productForm_strengthLow": "1 Niskie",
|
||||
"productForm_strengthMedium": "2 Średnie",
|
||||
"productForm_strengthHigh": "3 Wysokie",
|
||||
|
||||
"productForm_effectHydrationImmediate": "Nawilżenie (natychmiastowe)",
|
||||
"productForm_effectHydrationLongTerm": "Nawilżenie (długoterminowe)",
|
||||
"productForm_effectBarrierRepair": "Naprawa bariery",
|
||||
"productForm_effectSoothing": "Łagodzenie",
|
||||
"productForm_effectExfoliation": "Złuszczanie",
|
||||
"productForm_effectRetinoid": "Aktywność retinoidu",
|
||||
"productForm_effectIrritation": "Ryzyko podrażnienia",
|
||||
"productForm_effectComedogenic": "Ryzyko komedogenności",
|
||||
"productForm_effectBarrierDisruption": "Ryzyko naruszenia bariery",
|
||||
"productForm_effectDryness": "Ryzyko przesuszenia",
|
||||
"productForm_effectBrightening": "Rozjaśnienie",
|
||||
"productForm_effectAntiAcne": "Działanie przeciwtrądzikowe",
|
||||
"productForm_effectAntiAging": "Działanie przeciwstarzeniowe",
|
||||
"productForm_effectHydrationImmediate": "Nawilżenie (natychmiastowe)",
|
||||
"productForm_effectHydrationLongTerm": "Nawilżenie (długoterminowe)",
|
||||
"productForm_effectBarrierRepair": "Naprawa bariery",
|
||||
"productForm_effectSoothing": "Łagodzenie",
|
||||
"productForm_effectExfoliation": "Złuszczanie",
|
||||
"productForm_effectRetinoid": "Aktywność retinoidu",
|
||||
"productForm_effectIrritation": "Ryzyko podrażnienia",
|
||||
"productForm_effectComedogenic": "Ryzyko komedogenności",
|
||||
"productForm_effectBarrierDisruption": "Ryzyko naruszenia bariery",
|
||||
"productForm_effectDryness": "Ryzyko przesuszenia",
|
||||
"productForm_effectBrightening": "Rozjaśnienie",
|
||||
"productForm_effectAntiAcne": "Działanie przeciwtrądzikowe",
|
||||
"productForm_effectAntiAging": "Działanie przeciwstarzeniowe",
|
||||
|
||||
"lang_pl": "PL",
|
||||
"lang_en": "EN"
|
||||
"lang_pl": "PL",
|
||||
"lang_en": "EN"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,47 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.11.0",
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.3.6",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inlang/paraglide-js": "^2.13.0",
|
||||
"bits-ui": "^2.16.2",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-svelte": "^0.575.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
}
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@internationalized/date": "^3.11.0",
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint-plugin-svelte": "^3.15.0",
|
||||
"globals": "^17.4.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.5.0",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.3.6",
|
||||
"svelte-eslint-parser": "^1.5.1",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inlang/paraglide-js": "^2.13.0",
|
||||
"bits-ui": "^2.16.2",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-svelte": "^0.575.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"svelte-dnd-action": "^0.9.69",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3149
frontend/pnpm-lock.yaml
generated
3149
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"$schema": "https://inlang.com/schema/project-settings",
|
||||
"baseLocale": "pl",
|
||||
"locales": ["pl", "en"],
|
||||
"modules": [
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
|
||||
],
|
||||
"plugin.inlang.messageFormat": {
|
||||
"pathPattern": "./messages/{locale}.json"
|
||||
}
|
||||
"$schema": "https://inlang.com/schema/project-settings",
|
||||
"baseLocale": "pl",
|
||||
"locales": ["pl", "en"],
|
||||
"modules": [
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
|
||||
],
|
||||
"plugin.inlang.messageFormat": {
|
||||
"pathPattern": "./messages/{locale}.json"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,85 +5,85 @@
|
|||
/* ── CSS variable definitions (light / dark) ─────────────────────────────── */
|
||||
|
||||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(240 10% 3.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(240 10% 3.9%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(240 10% 3.9%);
|
||||
--primary: hsl(240 5.9% 10%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(240 4.8% 95.9%);
|
||||
--secondary-foreground: hsl(240 5.9% 10%);
|
||||
--muted: hsl(240 4.8% 95.9%);
|
||||
--muted-foreground: hsl(240 3.8% 46.1%);
|
||||
--accent: hsl(240 4.8% 95.9%);
|
||||
--accent-foreground: hsl(240 5.9% 10%);
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 5.9% 90%);
|
||||
--input: hsl(240 5.9% 90%);
|
||||
--ring: hsl(240 5.9% 10%);
|
||||
--radius: 0.5rem;
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(240 10% 3.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(240 10% 3.9%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(240 10% 3.9%);
|
||||
--primary: hsl(240 5.9% 10%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(240 4.8% 95.9%);
|
||||
--secondary-foreground: hsl(240 5.9% 10%);
|
||||
--muted: hsl(240 4.8% 95.9%);
|
||||
--muted-foreground: hsl(240 3.8% 46.1%);
|
||||
--accent: hsl(240 4.8% 95.9%);
|
||||
--accent-foreground: hsl(240 5.9% 10%);
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 5.9% 90%);
|
||||
--input: hsl(240 5.9% 90%);
|
||||
--ring: hsl(240 5.9% 10%);
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(240 10% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--card: hsl(240 10% 3.9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--popover: hsl(240 10% 3.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--primary: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(240 5.9% 10%);
|
||||
--secondary: hsl(240 3.7% 15.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(240 3.7% 15.9%);
|
||||
--muted-foreground: hsl(240 5% 64.9%);
|
||||
--accent: hsl(240 3.7% 15.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 3.7% 15.9%);
|
||||
--input: hsl(240 3.7% 15.9%);
|
||||
--ring: hsl(240 4.9% 83.9%);
|
||||
--background: hsl(240 10% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--card: hsl(240 10% 3.9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--popover: hsl(240 10% 3.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--primary: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(240 5.9% 10%);
|
||||
--secondary: hsl(240 3.7% 15.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(240 3.7% 15.9%);
|
||||
--muted-foreground: hsl(240 5% 64.9%);
|
||||
--accent: hsl(240 3.7% 15.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 3.7% 15.9%);
|
||||
--input: hsl(240 3.7% 15.9%);
|
||||
--ring: hsl(240 4.9% 83.9%);
|
||||
}
|
||||
|
||||
/* ── Map CSS vars → Tailwind v4 design tokens ────────────────────────────── */
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
/* ── Base resets ─────────────────────────────────────────────────────────── */
|
||||
|
||||
* {
|
||||
border-color: var(--border);
|
||||
border-color: var(--border);
|
||||
}
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
|
|
|||
14
frontend/src/app.d.ts
vendored
14
frontend/src/app.d.ts
vendored
|
|
@ -1,13 +1,13 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { paraglideMiddleware } from '$lib/paraglide/server.js';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { paraglideMiddleware } from "$lib/paraglide/server.js";
|
||||
import type { Handle } from "@sveltejs/kit";
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
return paraglideMiddleware(event.request, () => resolve(event));
|
||||
return paraglideMiddleware(event.request, () => resolve(event));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,271 +1,349 @@
|
|||
import { PUBLIC_API_BASE } from '$env/static/public';
|
||||
import { browser } from "$app/environment";
|
||||
import { PUBLIC_API_BASE } from "$env/static/public";
|
||||
import type {
|
||||
ActiveIngredient,
|
||||
BatchSuggestion,
|
||||
GroomingSchedule,
|
||||
LabResult,
|
||||
MedicationEntry,
|
||||
MedicationUsage,
|
||||
PartOfDay,
|
||||
Product,
|
||||
ProductContext,
|
||||
ProductEffectProfile,
|
||||
ProductInteraction,
|
||||
ProductInventory,
|
||||
Routine,
|
||||
RoutineSuggestion,
|
||||
RoutineStep,
|
||||
SkinConditionSnapshot
|
||||
} from './types';
|
||||
ActiveIngredient,
|
||||
BatchSuggestion,
|
||||
GroomingSchedule,
|
||||
LabResult,
|
||||
MedicationEntry,
|
||||
MedicationUsage,
|
||||
PartOfDay,
|
||||
Product,
|
||||
ProductContext,
|
||||
ProductEffectProfile,
|
||||
ProductInteraction,
|
||||
ProductInventory,
|
||||
Routine,
|
||||
RoutineSuggestion,
|
||||
RoutineStep,
|
||||
SkinConditionSnapshot,
|
||||
} from "./types";
|
||||
|
||||
// ─── Core fetch helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const url = `${PUBLIC_API_BASE}${path}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json', ...init.headers },
|
||||
...init
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(detail?.detail ?? res.statusText);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
// Server-side uses PUBLIC_API_BASE (e.g. http://localhost:8000).
|
||||
// Browser-side uses /api so nginx proxies the request on the correct host.
|
||||
const base = browser ? "/api" : PUBLIC_API_BASE;
|
||||
const url = `${base}${path}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { "Content-Type": "application/json", ...init.headers },
|
||||
...init,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(detail?.detail ?? res.statusText);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
del: (path: string) => request<void>(path, { method: 'DELETE' })
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
del: (path: string) => request<void>(path, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
// ─── Products ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProductListParams {
|
||||
category?: string;
|
||||
brand?: string;
|
||||
targets?: string[];
|
||||
is_medication?: boolean;
|
||||
is_tool?: boolean;
|
||||
category?: string;
|
||||
brand?: string;
|
||||
targets?: string[];
|
||||
is_medication?: boolean;
|
||||
is_tool?: boolean;
|
||||
}
|
||||
|
||||
export function getProducts(params: ProductListParams = {}): Promise<Product[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.category) q.set('category', params.category);
|
||||
if (params.brand) q.set('brand', params.brand);
|
||||
if (params.targets) params.targets.forEach((t) => q.append('targets', t));
|
||||
if (params.is_medication != null) q.set('is_medication', String(params.is_medication));
|
||||
if (params.is_tool != null) q.set('is_tool', String(params.is_tool));
|
||||
const qs = q.toString();
|
||||
return api.get(`/products${qs ? `?${qs}` : ''}`);
|
||||
export function getProducts(
|
||||
params: ProductListParams = {},
|
||||
): Promise<Product[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.category) q.set("category", params.category);
|
||||
if (params.brand) q.set("brand", params.brand);
|
||||
if (params.targets) params.targets.forEach((t) => q.append("targets", t));
|
||||
if (params.is_medication != null)
|
||||
q.set("is_medication", String(params.is_medication));
|
||||
if (params.is_tool != null) q.set("is_tool", String(params.is_tool));
|
||||
const qs = q.toString();
|
||||
return api.get(`/products${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
export const getProduct = (id: string): Promise<Product> => api.get(`/products/${id}`);
|
||||
export const createProduct = (body: Record<string, unknown>): Promise<Product> =>
|
||||
api.post('/products', body);
|
||||
export const updateProduct = (id: string, body: Record<string, unknown>): Promise<Product> =>
|
||||
api.patch(`/products/${id}`, body);
|
||||
export const deleteProduct = (id: string): Promise<void> => api.del(`/products/${id}`);
|
||||
export const getProduct = (id: string): Promise<Product> =>
|
||||
api.get(`/products/${id}`);
|
||||
export const createProduct = (
|
||||
body: Record<string, unknown>,
|
||||
): Promise<Product> => api.post("/products", body);
|
||||
export const updateProduct = (
|
||||
id: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<Product> => api.patch(`/products/${id}`, body);
|
||||
export const deleteProduct = (id: string): Promise<void> =>
|
||||
api.del(`/products/${id}`);
|
||||
|
||||
export const getInventory = (productId: string): Promise<ProductInventory[]> =>
|
||||
api.get(`/products/${productId}/inventory`);
|
||||
api.get(`/products/${productId}/inventory`);
|
||||
export const createInventory = (
|
||||
productId: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<ProductInventory> => api.post(`/products/${productId}/inventory`, body);
|
||||
export const updateInventory = (id: string, body: Record<string, unknown>): Promise<ProductInventory> =>
|
||||
api.patch(`/inventory/${id}`, body);
|
||||
export const deleteInventory = (id: string): Promise<void> => api.del(`/inventory/${id}`);
|
||||
productId: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<ProductInventory> =>
|
||||
api.post(`/products/${productId}/inventory`, body);
|
||||
export const updateInventory = (
|
||||
id: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<ProductInventory> => api.patch(`/inventory/${id}`, body);
|
||||
export const deleteInventory = (id: string): Promise<void> =>
|
||||
api.del(`/inventory/${id}`);
|
||||
|
||||
export interface ProductParseResponse {
|
||||
name?: string; brand?: string; line_name?: string; sku?: string; url?: string; barcode?: string;
|
||||
category?: string; recommended_time?: string; texture?: string; absorption_speed?: string;
|
||||
leave_on?: boolean; price_tier?: string;
|
||||
size_ml?: number; full_weight_g?: number; empty_weight_g?: number; pao_months?: number;
|
||||
inci?: string[]; actives?: ActiveIngredient[];
|
||||
recommended_for?: string[]; targets?: string[];
|
||||
contraindications?: string[]; usage_notes?: string;
|
||||
fragrance_free?: boolean; essential_oils_free?: boolean;
|
||||
alcohol_denat_free?: boolean; pregnancy_safe?: boolean;
|
||||
product_effect_profile?: ProductEffectProfile;
|
||||
ph_min?: number; ph_max?: number;
|
||||
incompatible_with?: ProductInteraction[]; synergizes_with?: string[];
|
||||
context_rules?: ProductContext;
|
||||
min_interval_hours?: number; max_frequency_per_week?: number;
|
||||
is_medication?: boolean; is_tool?: boolean; needle_length_mm?: number;
|
||||
name?: string;
|
||||
brand?: string;
|
||||
line_name?: string;
|
||||
sku?: string;
|
||||
url?: string;
|
||||
barcode?: string;
|
||||
category?: string;
|
||||
recommended_time?: string;
|
||||
texture?: string;
|
||||
absorption_speed?: string;
|
||||
leave_on?: boolean;
|
||||
price_tier?: string;
|
||||
size_ml?: number;
|
||||
full_weight_g?: number;
|
||||
empty_weight_g?: number;
|
||||
pao_months?: number;
|
||||
inci?: string[];
|
||||
actives?: ActiveIngredient[];
|
||||
recommended_for?: string[];
|
||||
targets?: string[];
|
||||
contraindications?: string[];
|
||||
usage_notes?: string;
|
||||
fragrance_free?: boolean;
|
||||
essential_oils_free?: boolean;
|
||||
alcohol_denat_free?: boolean;
|
||||
pregnancy_safe?: boolean;
|
||||
product_effect_profile?: ProductEffectProfile;
|
||||
ph_min?: number;
|
||||
ph_max?: number;
|
||||
incompatible_with?: ProductInteraction[];
|
||||
synergizes_with?: string[];
|
||||
context_rules?: ProductContext;
|
||||
min_interval_hours?: number;
|
||||
max_frequency_per_week?: number;
|
||||
is_medication?: boolean;
|
||||
is_tool?: boolean;
|
||||
needle_length_mm?: number;
|
||||
}
|
||||
|
||||
export const parseProductText = (text: string): Promise<ProductParseResponse> =>
|
||||
api.post('/products/parse-text', { text });
|
||||
api.post("/products/parse-text", { text });
|
||||
|
||||
// ─── Routines ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RoutineListParams {
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
part_of_day?: string;
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
part_of_day?: string;
|
||||
}
|
||||
|
||||
export function getRoutines(params: RoutineListParams = {}): Promise<Routine[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.from_date) q.set('from_date', params.from_date);
|
||||
if (params.to_date) q.set('to_date', params.to_date);
|
||||
if (params.part_of_day) q.set('part_of_day', params.part_of_day);
|
||||
const qs = q.toString();
|
||||
return api.get(`/routines${qs ? `?${qs}` : ''}`);
|
||||
export function getRoutines(
|
||||
params: RoutineListParams = {},
|
||||
): Promise<Routine[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.from_date) q.set("from_date", params.from_date);
|
||||
if (params.to_date) q.set("to_date", params.to_date);
|
||||
if (params.part_of_day) q.set("part_of_day", params.part_of_day);
|
||||
const qs = q.toString();
|
||||
return api.get(`/routines${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
export const getRoutine = (id: string): Promise<Routine> => api.get(`/routines/${id}`);
|
||||
export const createRoutine = (body: Record<string, unknown>): Promise<Routine> =>
|
||||
api.post('/routines', body);
|
||||
export const updateRoutine = (id: string, body: Record<string, unknown>): Promise<Routine> =>
|
||||
api.patch(`/routines/${id}`, body);
|
||||
export const deleteRoutine = (id: string): Promise<void> => api.del(`/routines/${id}`);
|
||||
export const getRoutine = (id: string): Promise<Routine> =>
|
||||
api.get(`/routines/${id}`);
|
||||
export const createRoutine = (
|
||||
body: Record<string, unknown>,
|
||||
): Promise<Routine> => api.post("/routines", body);
|
||||
export const updateRoutine = (
|
||||
id: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<Routine> => api.patch(`/routines/${id}`, body);
|
||||
export const deleteRoutine = (id: string): Promise<void> =>
|
||||
api.del(`/routines/${id}`);
|
||||
|
||||
export const addRoutineStep = (routineId: string, body: Record<string, unknown>): Promise<RoutineStep> =>
|
||||
api.post(`/routines/${routineId}/steps`, body);
|
||||
export const updateRoutineStep = (stepId: string, body: Record<string, unknown>): Promise<RoutineStep> =>
|
||||
api.patch(`/routines/steps/${stepId}`, body);
|
||||
export const addRoutineStep = (
|
||||
routineId: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<RoutineStep> => api.post(`/routines/${routineId}/steps`, body);
|
||||
export const updateRoutineStep = (
|
||||
stepId: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<RoutineStep> => api.patch(`/routines/steps/${stepId}`, body);
|
||||
export const deleteRoutineStep = (stepId: string): Promise<void> =>
|
||||
api.del(`/routines/steps/${stepId}`);
|
||||
api.del(`/routines/steps/${stepId}`);
|
||||
|
||||
export const suggestRoutine = (body: {
|
||||
routine_date: string;
|
||||
part_of_day: PartOfDay;
|
||||
notes?: string;
|
||||
}): Promise<RoutineSuggestion> => api.post('/routines/suggest', body);
|
||||
routine_date: string;
|
||||
part_of_day: PartOfDay;
|
||||
notes?: string;
|
||||
include_minoxidil_beard?: boolean;
|
||||
leaving_home?: boolean;
|
||||
}): Promise<RoutineSuggestion> => api.post("/routines/suggest", body);
|
||||
|
||||
export const suggestBatch = (body: {
|
||||
from_date: string;
|
||||
to_date: string;
|
||||
notes?: string;
|
||||
}): Promise<BatchSuggestion> => api.post('/routines/suggest-batch', body);
|
||||
from_date: string;
|
||||
to_date: string;
|
||||
notes?: string;
|
||||
include_minoxidil_beard?: boolean;
|
||||
minimize_products?: boolean;
|
||||
}): Promise<BatchSuggestion> => api.post("/routines/suggest-batch", body);
|
||||
|
||||
export const getGroomingSchedule = (): Promise<GroomingSchedule[]> =>
|
||||
api.get('/routines/grooming-schedule');
|
||||
export const createGroomingScheduleEntry = (body: Record<string, unknown>): Promise<GroomingSchedule> =>
|
||||
api.post('/routines/grooming-schedule', body);
|
||||
export const updateGroomingScheduleEntry = (id: string, body: Record<string, unknown>): Promise<GroomingSchedule> =>
|
||||
api.patch(`/routines/grooming-schedule/${id}`, body);
|
||||
api.get("/routines/grooming-schedule");
|
||||
export const createGroomingScheduleEntry = (
|
||||
body: Record<string, unknown>,
|
||||
): Promise<GroomingSchedule> => api.post("/routines/grooming-schedule", body);
|
||||
export const updateGroomingScheduleEntry = (
|
||||
id: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<GroomingSchedule> =>
|
||||
api.patch(`/routines/grooming-schedule/${id}`, body);
|
||||
export const deleteGroomingScheduleEntry = (id: string): Promise<void> =>
|
||||
api.del(`/routines/grooming-schedule/${id}`);
|
||||
api.del(`/routines/grooming-schedule/${id}`);
|
||||
|
||||
// ─── Health – Medications ────────────────────────────────────────────────────
|
||||
|
||||
export interface MedicationListParams {
|
||||
kind?: string;
|
||||
product_name?: string;
|
||||
kind?: string;
|
||||
product_name?: string;
|
||||
}
|
||||
|
||||
export function getMedications(params: MedicationListParams = {}): Promise<MedicationEntry[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.kind) q.set('kind', params.kind);
|
||||
if (params.product_name) q.set('product_name', params.product_name);
|
||||
const qs = q.toString();
|
||||
return api.get(`/health/medications${qs ? `?${qs}` : ''}`);
|
||||
export function getMedications(
|
||||
params: MedicationListParams = {},
|
||||
): Promise<MedicationEntry[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.kind) q.set("kind", params.kind);
|
||||
if (params.product_name) q.set("product_name", params.product_name);
|
||||
const qs = q.toString();
|
||||
return api.get(`/health/medications${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
export const getMedication = (id: string): Promise<MedicationEntry> =>
|
||||
api.get(`/health/medications/${id}`);
|
||||
export const createMedication = (body: Record<string, unknown>): Promise<MedicationEntry> =>
|
||||
api.post('/health/medications', body);
|
||||
api.get(`/health/medications/${id}`);
|
||||
export const createMedication = (
|
||||
body: Record<string, unknown>,
|
||||
): Promise<MedicationEntry> => api.post("/health/medications", body);
|
||||
export const updateMedication = (
|
||||
id: string,
|
||||
body: Record<string, unknown>
|
||||
id: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<MedicationEntry> => api.patch(`/health/medications/${id}`, body);
|
||||
export const deleteMedication = (id: string): Promise<void> =>
|
||||
api.del(`/health/medications/${id}`);
|
||||
api.del(`/health/medications/${id}`);
|
||||
|
||||
export const getMedicationUsages = (medicationId: string): Promise<MedicationUsage[]> =>
|
||||
api.get(`/health/medications/${medicationId}/usages`);
|
||||
export const getMedicationUsages = (
|
||||
medicationId: string,
|
||||
): Promise<MedicationUsage[]> =>
|
||||
api.get(`/health/medications/${medicationId}/usages`);
|
||||
export const createMedicationUsage = (
|
||||
medicationId: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<MedicationUsage> => api.post(`/health/medications/${medicationId}/usages`, body);
|
||||
medicationId: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<MedicationUsage> =>
|
||||
api.post(`/health/medications/${medicationId}/usages`, body);
|
||||
|
||||
// ─── Health – Lab results ────────────────────────────────────────────────────
|
||||
|
||||
export interface LabResultListParams {
|
||||
test_code?: string;
|
||||
flag?: string;
|
||||
lab?: string;
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
test_code?: string;
|
||||
flag?: string;
|
||||
lab?: string;
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
}
|
||||
|
||||
export function getLabResults(params: LabResultListParams = {}): Promise<LabResult[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.test_code) q.set('test_code', params.test_code);
|
||||
if (params.flag) q.set('flag', params.flag);
|
||||
if (params.lab) q.set('lab', params.lab);
|
||||
if (params.from_date) q.set('from_date', params.from_date);
|
||||
if (params.to_date) q.set('to_date', params.to_date);
|
||||
const qs = q.toString();
|
||||
return api.get(`/health/lab-results${qs ? `?${qs}` : ''}`);
|
||||
export function getLabResults(
|
||||
params: LabResultListParams = {},
|
||||
): Promise<LabResult[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.test_code) q.set("test_code", params.test_code);
|
||||
if (params.flag) q.set("flag", params.flag);
|
||||
if (params.lab) q.set("lab", params.lab);
|
||||
if (params.from_date) q.set("from_date", params.from_date);
|
||||
if (params.to_date) q.set("to_date", params.to_date);
|
||||
const qs = q.toString();
|
||||
return api.get(`/health/lab-results${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
export const getLabResult = (id: string): Promise<LabResult> =>
|
||||
api.get(`/health/lab-results/${id}`);
|
||||
export const createLabResult = (body: Record<string, unknown>): Promise<LabResult> =>
|
||||
api.post('/health/lab-results', body);
|
||||
export const updateLabResult = (id: string, body: Record<string, unknown>): Promise<LabResult> =>
|
||||
api.patch(`/health/lab-results/${id}`, body);
|
||||
api.get(`/health/lab-results/${id}`);
|
||||
export const createLabResult = (
|
||||
body: Record<string, unknown>,
|
||||
): Promise<LabResult> => api.post("/health/lab-results", body);
|
||||
export const updateLabResult = (
|
||||
id: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<LabResult> => api.patch(`/health/lab-results/${id}`, body);
|
||||
export const deleteLabResult = (id: string): Promise<void> =>
|
||||
api.del(`/health/lab-results/${id}`);
|
||||
api.del(`/health/lab-results/${id}`);
|
||||
|
||||
// ─── Skin ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SnapshotListParams {
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
overall_state?: string;
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
overall_state?: string;
|
||||
}
|
||||
|
||||
export function getSkinSnapshots(params: SnapshotListParams = {}): Promise<SkinConditionSnapshot[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.from_date) q.set('from_date', params.from_date);
|
||||
if (params.to_date) q.set('to_date', params.to_date);
|
||||
if (params.overall_state) q.set('overall_state', params.overall_state);
|
||||
const qs = q.toString();
|
||||
return api.get(`/skincare${qs ? `?${qs}` : ''}`);
|
||||
export function getSkinSnapshots(
|
||||
params: SnapshotListParams = {},
|
||||
): Promise<SkinConditionSnapshot[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.from_date) q.set("from_date", params.from_date);
|
||||
if (params.to_date) q.set("to_date", params.to_date);
|
||||
if (params.overall_state) q.set("overall_state", params.overall_state);
|
||||
const qs = q.toString();
|
||||
return api.get(`/skincare${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
export const getSkinSnapshot = (id: string): Promise<SkinConditionSnapshot> =>
|
||||
api.get(`/skincare/${id}`);
|
||||
export const createSkinSnapshot = (body: Record<string, unknown>): Promise<SkinConditionSnapshot> =>
|
||||
api.post('/skincare', body);
|
||||
api.get(`/skincare/${id}`);
|
||||
export const createSkinSnapshot = (
|
||||
body: Record<string, unknown>,
|
||||
): Promise<SkinConditionSnapshot> => api.post("/skincare", body);
|
||||
export const updateSkinSnapshot = (
|
||||
id: string,
|
||||
body: Record<string, unknown>
|
||||
id: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<SkinConditionSnapshot> => api.patch(`/skincare/${id}`, body);
|
||||
export const deleteSkinSnapshot = (id: string): Promise<void> => api.del(`/skincare/${id}`);
|
||||
export const deleteSkinSnapshot = (id: string): Promise<void> =>
|
||||
api.del(`/skincare/${id}`);
|
||||
|
||||
export interface SkinPhotoAnalysisResponse {
|
||||
overall_state?: string;
|
||||
texture?: string;
|
||||
skin_type?: string;
|
||||
hydration_level?: number;
|
||||
sebum_tzone?: number;
|
||||
sebum_cheeks?: number;
|
||||
sensitivity_level?: number;
|
||||
barrier_state?: string;
|
||||
active_concerns?: string[];
|
||||
risks?: string[];
|
||||
priorities?: string[];
|
||||
notes?: string;
|
||||
overall_state?: string;
|
||||
texture?: string;
|
||||
skin_type?: string;
|
||||
hydration_level?: number;
|
||||
sebum_tzone?: number;
|
||||
sebum_cheeks?: number;
|
||||
sensitivity_level?: number;
|
||||
barrier_state?: string;
|
||||
active_concerns?: string[];
|
||||
risks?: string[];
|
||||
priorities?: string[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export async function analyzeSkinPhotos(files: File[]): Promise<SkinPhotoAnalysisResponse> {
|
||||
const body = new FormData();
|
||||
for (const file of files) body.append('photos', file);
|
||||
const res = await fetch(`${PUBLIC_API_BASE}/skincare/analyze-photos`, { method: 'POST', body });
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(detail?.detail ?? res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
export async function analyzeSkinPhotos(
|
||||
files: File[],
|
||||
): Promise<SkinPhotoAnalysisResponse> {
|
||||
const body = new FormData();
|
||||
for (const file of files) body.append("photos", file);
|
||||
const base = browser ? "/api" : PUBLIC_API_BASE;
|
||||
const res = await fetch(`${base}/skincare/analyze-photos`, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(detail?.detail ?? res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -451,7 +451,7 @@
|
|||
<Card>
|
||||
<CardHeader><CardTitle>{m["productForm_basicInfo"]()}</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="name">{m["productForm_name"]()}</Label>
|
||||
<Input id="name" name="name" required placeholder={m["productForm_namePlaceholder"]()} bind:value={name} />
|
||||
|
|
@ -461,7 +461,7 @@
|
|||
<Input id="brand" name="brand" required placeholder={m["productForm_brandPlaceholder"]()} bind:value={brand} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="line_name">{m["productForm_lineName"]()}</Label>
|
||||
<Input id="line_name" name="line_name" placeholder={m["productForm_lineNamePlaceholder"]()} bind:value={lineName} />
|
||||
|
|
@ -471,7 +471,7 @@
|
|||
<Input id="url" name="url" type="url" placeholder="https://…" bind:value={url} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="sku">{m["productForm_sku"]()}</Label>
|
||||
<Input id="sku" name="sku" placeholder={m["productForm_skuPlaceholder"]()} bind:value={sku} />
|
||||
|
|
@ -488,7 +488,7 @@
|
|||
<Card>
|
||||
<CardHeader><CardTitle>{m["productForm_classification"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="col-span-2 space-y-2">
|
||||
<Label>{m["productForm_category"]()}</Label>
|
||||
<input type="hidden" name="category" value={category} />
|
||||
|
|
@ -564,7 +564,7 @@
|
|||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>{m["productForm_recommendedFor"]()}</Label>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{#each skinTypes as st}
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input
|
||||
|
|
@ -588,7 +588,7 @@
|
|||
|
||||
<div class="space-y-2">
|
||||
<Label>{m["productForm_targetConcerns"]()}</Label>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{#each skinConcerns as sc}
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input
|
||||
|
|
@ -641,7 +641,7 @@
|
|||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Label>{m["productForm_activeIngredients"]()}</Label>
|
||||
<Button type="button" variant="outline" size="sm" onclick={addActive}>{m["productForm_addActive"]()}</Button>
|
||||
</div>
|
||||
|
|
@ -650,14 +650,23 @@
|
|||
|
||||
{#each actives as active, i}
|
||||
<div class="rounded-md border border-border p-3 space-y-3">
|
||||
<div class="grid grid-cols-[1fr_100px_120px_120px_auto] gap-2 items-end">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<Label class="text-xs">{m["productForm_activeName"]()}</Label>
|
||||
<Input
|
||||
placeholder="e.g. Niacinamide"
|
||||
bind:value={active.name}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => removeActive(i)}
|
||||
class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive"
|
||||
>✕</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs">{m["productForm_activePercent"]()}</Label>
|
||||
<Input
|
||||
|
|
@ -687,18 +696,11 @@
|
|||
<option value="3">{m["productForm_strengthHigh"]()}</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => removeActive(i)}
|
||||
class="text-destructive hover:text-destructive"
|
||||
>✕</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs text-muted-foreground">{m["productForm_activeFunctions"]()}</Label>
|
||||
<div class="grid grid-cols-4 gap-1">
|
||||
<div class="grid grid-cols-2 gap-1 sm:grid-cols-4">
|
||||
{#each ingFunctions as fn}
|
||||
<label class="flex cursor-pointer items-center gap-1.5 text-xs">
|
||||
<input
|
||||
|
|
@ -764,7 +766,7 @@
|
|||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Label>{m["productForm_incompatibleWith"]()}</Label>
|
||||
<Button type="button" variant="outline" size="sm" onclick={addIncompatible}>
|
||||
{m["productForm_addIncompatibility"]()}
|
||||
|
|
@ -774,7 +776,7 @@
|
|||
<input type="hidden" name="incompatible_with_json" value={incompatibleJson} />
|
||||
|
||||
{#each incompatibleWith as row, i}
|
||||
<div class="grid grid-cols-[1fr_140px_1fr_auto] gap-2 items-end">
|
||||
<div class="grid grid-cols-2 gap-2 items-end sm:grid-cols-[1fr_140px_1fr_auto]">
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs">{m["productForm_incompTarget"]()}</Label>
|
||||
<Input placeholder="e.g. Vitamin C" bind:value={row.target} />
|
||||
|
|
@ -813,7 +815,7 @@
|
|||
<Card>
|
||||
<CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label>{m["productForm_ctxAfterShaving"]()}</Label>
|
||||
<input type="hidden" name="ctx_safe_after_shaving" value={ctxAfterShaving} />
|
||||
|
|
@ -884,7 +886,7 @@
|
|||
<Card>
|
||||
<CardHeader><CardTitle>{m["productForm_productDetails"]()}</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
<Label>{m["productForm_priceTier"]()}</Label>
|
||||
<input type="hidden" name="price_tier" value={priceTier} />
|
||||
|
|
@ -919,7 +921,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="ph_min">{m["productForm_phMin"]()}</Label>
|
||||
<Input id="ph_min" name="ph_min" type="number" min="0" max="14" step="0.1" placeholder="e.g. 3.5" bind:value={phMin} />
|
||||
|
|
@ -948,7 +950,7 @@
|
|||
<Card>
|
||||
<CardHeader><CardTitle>{m["productForm_safetyFlags"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label>{m["productForm_fragranceFree"]()}</Label>
|
||||
<input type="hidden" name="fragrance_free" value={fragranceFree} />
|
||||
|
|
@ -1008,7 +1010,7 @@
|
|||
<Card>
|
||||
<CardHeader><CardTitle>{m["productForm_usageConstraints"]()}</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="min_interval_hours">{m["productForm_minIntervalHours"]()}</Label>
|
||||
<Input id="min_interval_hours" name="min_interval_hours" type="number" min="0" placeholder="e.g. 24" bind:value={minIntervalHours} />
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import Root, {
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants,
|
||||
} from "./button.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
type ButtonProps as Props,
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
Root,
|
||||
type ButtonProps as Props,
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,19 +7,19 @@ import Title from "./card-title.svelte";
|
|||
import Action from "./card-action.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Description,
|
||||
Footer,
|
||||
Header,
|
||||
Title,
|
||||
Action,
|
||||
//
|
||||
Root as Card,
|
||||
Content as CardContent,
|
||||
Description as CardDescription,
|
||||
Footer as CardFooter,
|
||||
Header as CardHeader,
|
||||
Title as CardTitle,
|
||||
Action as CardAction,
|
||||
Root,
|
||||
Content,
|
||||
Description,
|
||||
Footer,
|
||||
Header,
|
||||
Title,
|
||||
Action,
|
||||
//
|
||||
Root as Card,
|
||||
Content as CardContent,
|
||||
Description as CardDescription,
|
||||
Footer as CardFooter,
|
||||
Header as CardHeader,
|
||||
Title as CardTitle,
|
||||
Action as CardAction,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,27 +11,27 @@ import GroupHeading from "./select-group-heading.svelte";
|
|||
import Portal from "./select-portal.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Group,
|
||||
Label,
|
||||
Item,
|
||||
Content,
|
||||
Trigger,
|
||||
Separator,
|
||||
ScrollDownButton,
|
||||
ScrollUpButton,
|
||||
GroupHeading,
|
||||
Portal,
|
||||
//
|
||||
Root as Select,
|
||||
Group as SelectGroup,
|
||||
Label as SelectLabel,
|
||||
Item as SelectItem,
|
||||
Content as SelectContent,
|
||||
Trigger as SelectTrigger,
|
||||
Separator as SelectSeparator,
|
||||
ScrollDownButton as SelectScrollDownButton,
|
||||
ScrollUpButton as SelectScrollUpButton,
|
||||
GroupHeading as SelectGroupHeading,
|
||||
Portal as SelectPortal,
|
||||
Root,
|
||||
Group,
|
||||
Label,
|
||||
Item,
|
||||
Content,
|
||||
Trigger,
|
||||
Separator,
|
||||
ScrollDownButton,
|
||||
ScrollUpButton,
|
||||
GroupHeading,
|
||||
Portal,
|
||||
//
|
||||
Root as Select,
|
||||
Group as SelectGroup,
|
||||
Label as SelectLabel,
|
||||
Item as SelectItem,
|
||||
Content as SelectContent,
|
||||
Trigger as SelectTrigger,
|
||||
Separator as SelectSeparator,
|
||||
ScrollDownButton as SelectScrollDownButton,
|
||||
ScrollUpButton as SelectScrollUpButton,
|
||||
GroupHeading as SelectGroupHeading,
|
||||
Portal as SelectPortal,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import Root from "./separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,21 +8,21 @@ import Header from "./table-header.svelte";
|
|||
import Row from "./table-row.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Body,
|
||||
Caption,
|
||||
Cell,
|
||||
Footer,
|
||||
Head,
|
||||
Header,
|
||||
Row,
|
||||
//
|
||||
Root as Table,
|
||||
Body as TableBody,
|
||||
Caption as TableCaption,
|
||||
Cell as TableCell,
|
||||
Footer as TableFooter,
|
||||
Head as TableHead,
|
||||
Header as TableHeader,
|
||||
Row as TableRow,
|
||||
Root,
|
||||
Body,
|
||||
Caption,
|
||||
Cell,
|
||||
Footer,
|
||||
Head,
|
||||
Header,
|
||||
Row,
|
||||
//
|
||||
Root as Table,
|
||||
Body as TableBody,
|
||||
Caption as TableCaption,
|
||||
Cell as TableCell,
|
||||
Footer as TableFooter,
|
||||
Head as TableHead,
|
||||
Header as TableHeader,
|
||||
Row as TableRow,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import List from "./tabs-list.svelte";
|
|||
import Trigger from "./tabs-trigger.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
List,
|
||||
Trigger,
|
||||
//
|
||||
Root as Tabs,
|
||||
Content as TabsContent,
|
||||
List as TabsList,
|
||||
Trigger as TabsTrigger,
|
||||
Root,
|
||||
Content,
|
||||
List,
|
||||
Trigger,
|
||||
//
|
||||
Root as Tabs,
|
||||
Content as TabsContent,
|
||||
List as TabsList,
|
||||
Trigger as TabsTrigger,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,291 +1,344 @@
|
|||
// ─── Enums ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AbsorptionSpeed = 'very_fast' | 'fast' | 'moderate' | 'slow' | 'very_slow';
|
||||
export type BarrierState = 'intact' | 'mildly_compromised' | 'compromised';
|
||||
export type DayTime = 'am' | 'pm' | 'both';
|
||||
export type GroomingAction = 'shaving_razor' | 'shaving_oneblade' | 'dermarolling';
|
||||
export type AbsorptionSpeed =
|
||||
| "very_fast"
|
||||
| "fast"
|
||||
| "moderate"
|
||||
| "slow"
|
||||
| "very_slow";
|
||||
export type BarrierState = "intact" | "mildly_compromised" | "compromised";
|
||||
export type DayTime = "am" | "pm" | "both";
|
||||
export type GroomingAction =
|
||||
| "shaving_razor"
|
||||
| "shaving_oneblade"
|
||||
| "dermarolling";
|
||||
export type IngredientFunction =
|
||||
| 'humectant'
|
||||
| 'emollient'
|
||||
| 'occlusive'
|
||||
| 'exfoliant_aha'
|
||||
| 'exfoliant_bha'
|
||||
| 'exfoliant_pha'
|
||||
| 'retinoid'
|
||||
| 'antioxidant'
|
||||
| 'soothing'
|
||||
| 'barrier_support'
|
||||
| 'brightening'
|
||||
| 'anti_acne'
|
||||
| 'ceramide'
|
||||
| 'niacinamide'
|
||||
| 'sunscreen'
|
||||
| 'peptide'
|
||||
| 'hair_growth_stimulant'
|
||||
| 'prebiotic'
|
||||
| 'vitamin_c'
|
||||
| 'anti_aging';
|
||||
export type InteractionScope = 'same_step' | 'same_day' | 'same_period';
|
||||
export type MedicationKind = 'prescription' | 'otc' | 'supplement' | 'herbal' | 'other';
|
||||
export type OverallSkinState = 'excellent' | 'good' | 'fair' | 'poor';
|
||||
export type PartOfDay = 'am' | 'pm';
|
||||
export type PriceTier = 'budget' | 'mid' | 'premium' | 'luxury';
|
||||
| "humectant"
|
||||
| "emollient"
|
||||
| "occlusive"
|
||||
| "exfoliant_aha"
|
||||
| "exfoliant_bha"
|
||||
| "exfoliant_pha"
|
||||
| "retinoid"
|
||||
| "antioxidant"
|
||||
| "soothing"
|
||||
| "barrier_support"
|
||||
| "brightening"
|
||||
| "anti_acne"
|
||||
| "ceramide"
|
||||
| "niacinamide"
|
||||
| "sunscreen"
|
||||
| "peptide"
|
||||
| "hair_growth_stimulant"
|
||||
| "prebiotic"
|
||||
| "vitamin_c"
|
||||
| "anti_aging";
|
||||
export type InteractionScope = "same_step" | "same_day" | "same_period";
|
||||
export type MedicationKind =
|
||||
| "prescription"
|
||||
| "otc"
|
||||
| "supplement"
|
||||
| "herbal"
|
||||
| "other";
|
||||
export type OverallSkinState = "excellent" | "good" | "fair" | "poor";
|
||||
export type PartOfDay = "am" | "pm";
|
||||
export type PriceTier = "budget" | "mid" | "premium" | "luxury";
|
||||
export type ProductCategory =
|
||||
| 'cleanser'
|
||||
| 'toner'
|
||||
| 'essence'
|
||||
| 'serum'
|
||||
| 'moisturizer'
|
||||
| 'spf'
|
||||
| 'mask'
|
||||
| 'exfoliant'
|
||||
| 'hair_treatment'
|
||||
| 'tool'
|
||||
| 'spot_treatment'
|
||||
| 'oil';
|
||||
export type ResultFlag = 'N' | 'ABN' | 'POS' | 'NEG' | 'L' | 'H';
|
||||
| "cleanser"
|
||||
| "toner"
|
||||
| "essence"
|
||||
| "serum"
|
||||
| "moisturizer"
|
||||
| "spf"
|
||||
| "mask"
|
||||
| "exfoliant"
|
||||
| "hair_treatment"
|
||||
| "tool"
|
||||
| "spot_treatment"
|
||||
| "oil";
|
||||
export type ResultFlag = "N" | "ABN" | "POS" | "NEG" | "L" | "H";
|
||||
export type SkinConcern =
|
||||
| 'acne'
|
||||
| 'rosacea'
|
||||
| 'hyperpigmentation'
|
||||
| 'aging'
|
||||
| 'dehydration'
|
||||
| 'redness'
|
||||
| 'damaged_barrier'
|
||||
| 'pore_visibility'
|
||||
| 'uneven_texture'
|
||||
| 'hair_growth'
|
||||
| 'sebum_excess';
|
||||
export type SkinTexture = 'smooth' | 'rough' | 'flaky' | 'bumpy';
|
||||
export type SkinType = 'dry' | 'oily' | 'combination' | 'sensitive' | 'normal' | 'acne_prone';
|
||||
| "acne"
|
||||
| "rosacea"
|
||||
| "hyperpigmentation"
|
||||
| "aging"
|
||||
| "dehydration"
|
||||
| "redness"
|
||||
| "damaged_barrier"
|
||||
| "pore_visibility"
|
||||
| "uneven_texture"
|
||||
| "hair_growth"
|
||||
| "sebum_excess";
|
||||
export type SkinTexture = "smooth" | "rough" | "flaky" | "bumpy";
|
||||
export type SkinType =
|
||||
| "dry"
|
||||
| "oily"
|
||||
| "combination"
|
||||
| "sensitive"
|
||||
| "normal"
|
||||
| "acne_prone";
|
||||
export type StrengthLevel = 1 | 2 | 3;
|
||||
export type TextureType = 'watery' | 'gel' | 'emulsion' | 'cream' | 'oil' | 'balm' | 'foam' | 'fluid';
|
||||
export type TextureType =
|
||||
| "watery"
|
||||
| "gel"
|
||||
| "emulsion"
|
||||
| "cream"
|
||||
| "oil"
|
||||
| "balm"
|
||||
| "foam"
|
||||
| "fluid";
|
||||
// ─── Product types ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface ActiveIngredient {
|
||||
name: string;
|
||||
percent?: number;
|
||||
functions: IngredientFunction[];
|
||||
strength_level?: StrengthLevel;
|
||||
irritation_potential?: StrengthLevel;
|
||||
name: string;
|
||||
percent?: number;
|
||||
functions: IngredientFunction[];
|
||||
strength_level?: StrengthLevel;
|
||||
irritation_potential?: StrengthLevel;
|
||||
}
|
||||
|
||||
export interface ProductEffectProfile {
|
||||
hydration_immediate: number;
|
||||
hydration_long_term: number;
|
||||
barrier_repair_strength: number;
|
||||
soothing_strength: number;
|
||||
exfoliation_strength: number;
|
||||
retinoid_strength: number;
|
||||
irritation_risk: number;
|
||||
comedogenic_risk: number;
|
||||
barrier_disruption_risk: number;
|
||||
dryness_risk: number;
|
||||
brightening_strength: number;
|
||||
anti_acne_strength: number;
|
||||
anti_aging_strength: number;
|
||||
hydration_immediate: number;
|
||||
hydration_long_term: number;
|
||||
barrier_repair_strength: number;
|
||||
soothing_strength: number;
|
||||
exfoliation_strength: number;
|
||||
retinoid_strength: number;
|
||||
irritation_risk: number;
|
||||
comedogenic_risk: number;
|
||||
barrier_disruption_risk: number;
|
||||
dryness_risk: number;
|
||||
brightening_strength: number;
|
||||
anti_acne_strength: number;
|
||||
anti_aging_strength: number;
|
||||
}
|
||||
|
||||
export interface ProductInteraction {
|
||||
target: string;
|
||||
scope: InteractionScope;
|
||||
reason?: string;
|
||||
target: string;
|
||||
scope: InteractionScope;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ProductContext {
|
||||
safe_after_shaving?: boolean;
|
||||
safe_after_acids?: boolean;
|
||||
safe_after_retinoids?: boolean;
|
||||
safe_with_compromised_barrier?: boolean;
|
||||
low_uv_only?: boolean;
|
||||
safe_after_shaving?: boolean;
|
||||
safe_after_acids?: boolean;
|
||||
safe_after_retinoids?: boolean;
|
||||
safe_with_compromised_barrier?: boolean;
|
||||
low_uv_only?: boolean;
|
||||
}
|
||||
|
||||
export interface ProductInventory {
|
||||
id: string;
|
||||
product_id: string;
|
||||
is_opened: boolean;
|
||||
opened_at?: string;
|
||||
finished_at?: string;
|
||||
expiry_date?: string;
|
||||
current_weight_g?: number;
|
||||
last_weighed_at?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
product?: Product;
|
||||
id: string;
|
||||
product_id: string;
|
||||
is_opened: boolean;
|
||||
opened_at?: string;
|
||||
finished_at?: string;
|
||||
expiry_date?: string;
|
||||
current_weight_g?: number;
|
||||
last_weighed_at?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
product?: Product;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
line_name?: string;
|
||||
sku?: string;
|
||||
url?: string;
|
||||
barcode?: string;
|
||||
category: ProductCategory;
|
||||
recommended_time: DayTime;
|
||||
texture?: TextureType;
|
||||
absorption_speed?: AbsorptionSpeed;
|
||||
leave_on: boolean;
|
||||
price_tier?: PriceTier;
|
||||
size_ml?: number;
|
||||
full_weight_g?: number;
|
||||
empty_weight_g?: number;
|
||||
pao_months?: number;
|
||||
inci: string[];
|
||||
actives?: ActiveIngredient[];
|
||||
recommended_for: SkinType[];
|
||||
targets: SkinConcern[];
|
||||
contraindications: string[];
|
||||
usage_notes?: string;
|
||||
fragrance_free?: boolean;
|
||||
essential_oils_free?: boolean;
|
||||
alcohol_denat_free?: boolean;
|
||||
pregnancy_safe?: boolean;
|
||||
product_effect_profile: ProductEffectProfile;
|
||||
ph_min?: number;
|
||||
ph_max?: number;
|
||||
incompatible_with?: ProductInteraction[];
|
||||
synergizes_with?: string[];
|
||||
context_rules?: ProductContext;
|
||||
min_interval_hours?: number;
|
||||
max_frequency_per_week?: number;
|
||||
is_medication: boolean;
|
||||
is_tool: boolean;
|
||||
needle_length_mm?: number;
|
||||
personal_tolerance_notes?: string;
|
||||
personal_repurchase_intent?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
inventory: ProductInventory[];
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
line_name?: string;
|
||||
sku?: string;
|
||||
url?: string;
|
||||
barcode?: string;
|
||||
category: ProductCategory;
|
||||
recommended_time: DayTime;
|
||||
texture?: TextureType;
|
||||
absorption_speed?: AbsorptionSpeed;
|
||||
leave_on: boolean;
|
||||
price_tier?: PriceTier;
|
||||
size_ml?: number;
|
||||
full_weight_g?: number;
|
||||
empty_weight_g?: number;
|
||||
pao_months?: number;
|
||||
inci: string[];
|
||||
actives?: ActiveIngredient[];
|
||||
recommended_for: SkinType[];
|
||||
targets: SkinConcern[];
|
||||
contraindications: string[];
|
||||
usage_notes?: string;
|
||||
fragrance_free?: boolean;
|
||||
essential_oils_free?: boolean;
|
||||
alcohol_denat_free?: boolean;
|
||||
pregnancy_safe?: boolean;
|
||||
product_effect_profile: ProductEffectProfile;
|
||||
ph_min?: number;
|
||||
ph_max?: number;
|
||||
incompatible_with?: ProductInteraction[];
|
||||
synergizes_with?: string[];
|
||||
context_rules?: ProductContext;
|
||||
min_interval_hours?: number;
|
||||
max_frequency_per_week?: number;
|
||||
is_medication: boolean;
|
||||
is_tool: boolean;
|
||||
needle_length_mm?: number;
|
||||
personal_tolerance_notes?: string;
|
||||
personal_repurchase_intent?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
inventory: ProductInventory[];
|
||||
}
|
||||
|
||||
// ─── Routine types ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface RoutineStep {
|
||||
id: string;
|
||||
routine_id: string;
|
||||
product_id?: string;
|
||||
order_index: number;
|
||||
action_type?: GroomingAction;
|
||||
action_notes?: string;
|
||||
dose?: string;
|
||||
region?: string;
|
||||
product?: Product;
|
||||
id: string;
|
||||
routine_id: string;
|
||||
product_id?: string;
|
||||
order_index: number;
|
||||
action_type?: GroomingAction;
|
||||
action_notes?: string;
|
||||
dose?: string;
|
||||
region?: string;
|
||||
product?: Product;
|
||||
}
|
||||
|
||||
export interface Routine {
|
||||
id: string;
|
||||
routine_date: string;
|
||||
part_of_day: PartOfDay;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
steps?: RoutineStep[];
|
||||
id: string;
|
||||
routine_date: string;
|
||||
part_of_day: PartOfDay;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
steps?: RoutineStep[];
|
||||
}
|
||||
|
||||
export interface GroomingSchedule {
|
||||
id: string;
|
||||
day_of_week: number;
|
||||
action: GroomingAction;
|
||||
notes?: string;
|
||||
id: string;
|
||||
day_of_week: number;
|
||||
action: GroomingAction;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface SuggestedStep {
|
||||
product_id?: string;
|
||||
action_type?: GroomingAction;
|
||||
action_notes?: string;
|
||||
dose?: string;
|
||||
region?: string;
|
||||
product_id?: string;
|
||||
action_type?: GroomingAction;
|
||||
action_notes?: string;
|
||||
dose?: string;
|
||||
region?: string;
|
||||
why_this_step?: string;
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
export interface RoutineSuggestionSummary {
|
||||
primary_goal: string;
|
||||
constraints_applied: string[];
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface RoutineSuggestion {
|
||||
steps: SuggestedStep[];
|
||||
reasoning: string;
|
||||
steps: SuggestedStep[];
|
||||
reasoning: string;
|
||||
summary?: RoutineSuggestionSummary;
|
||||
}
|
||||
|
||||
export interface DayPlan {
|
||||
date: string;
|
||||
am_steps: SuggestedStep[];
|
||||
pm_steps: SuggestedStep[];
|
||||
reasoning: string;
|
||||
date: string;
|
||||
am_steps: SuggestedStep[];
|
||||
pm_steps: SuggestedStep[];
|
||||
reasoning: string;
|
||||
}
|
||||
|
||||
export interface BatchSuggestion {
|
||||
days: DayPlan[];
|
||||
overall_reasoning: string;
|
||||
days: DayPlan[];
|
||||
overall_reasoning: string;
|
||||
}
|
||||
|
||||
// ─── Shopping suggestion types ───────────────────────────────────────────────
|
||||
|
||||
export interface ProductSuggestion {
|
||||
category: string;
|
||||
product_type: string;
|
||||
key_ingredients: string[];
|
||||
target_concerns: string[];
|
||||
why_needed: string;
|
||||
recommended_time: string;
|
||||
frequency: string;
|
||||
}
|
||||
|
||||
export interface ShoppingSuggestionResponse {
|
||||
suggestions: ProductSuggestion[];
|
||||
reasoning: string;
|
||||
}
|
||||
|
||||
// ─── Health types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MedicationUsage {
|
||||
record_id: string;
|
||||
medication_record_id: string;
|
||||
dose_value?: number;
|
||||
dose_unit?: string;
|
||||
frequency?: string;
|
||||
schedule_text?: string;
|
||||
as_needed: boolean;
|
||||
valid_from: string;
|
||||
valid_to?: string;
|
||||
source_file?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
record_id: string;
|
||||
medication_record_id: string;
|
||||
dose_value?: number;
|
||||
dose_unit?: string;
|
||||
frequency?: string;
|
||||
schedule_text?: string;
|
||||
as_needed: boolean;
|
||||
valid_from: string;
|
||||
valid_to?: string;
|
||||
source_file?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MedicationEntry {
|
||||
record_id: string;
|
||||
kind: MedicationKind;
|
||||
product_name: string;
|
||||
active_substance?: string;
|
||||
formulation?: string;
|
||||
route?: string;
|
||||
source_file?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
usage_history: MedicationUsage[];
|
||||
record_id: string;
|
||||
kind: MedicationKind;
|
||||
product_name: string;
|
||||
active_substance?: string;
|
||||
formulation?: string;
|
||||
route?: string;
|
||||
source_file?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
usage_history: MedicationUsage[];
|
||||
}
|
||||
|
||||
export interface LabResult {
|
||||
record_id: string;
|
||||
collected_at: string;
|
||||
test_code: string;
|
||||
test_name_original?: string;
|
||||
test_name_loinc?: string;
|
||||
value_num?: number;
|
||||
value_text?: string;
|
||||
value_bool?: boolean;
|
||||
unit_original?: string;
|
||||
unit_ucum?: string;
|
||||
ref_low?: number;
|
||||
ref_high?: number;
|
||||
ref_text?: string;
|
||||
flag?: ResultFlag;
|
||||
lab?: string;
|
||||
source_file?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
record_id: string;
|
||||
collected_at: string;
|
||||
test_code: string;
|
||||
test_name_original?: string;
|
||||
test_name_loinc?: string;
|
||||
value_num?: number;
|
||||
value_text?: string;
|
||||
value_bool?: boolean;
|
||||
unit_original?: string;
|
||||
unit_ucum?: string;
|
||||
ref_low?: number;
|
||||
ref_high?: number;
|
||||
ref_text?: string;
|
||||
flag?: ResultFlag;
|
||||
lab?: string;
|
||||
source_file?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ─── Skin types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SkinConditionSnapshot {
|
||||
id: string;
|
||||
snapshot_date: string;
|
||||
overall_state?: OverallSkinState;
|
||||
skin_type?: SkinType;
|
||||
texture?: SkinTexture;
|
||||
hydration_level?: number;
|
||||
sebum_tzone?: number;
|
||||
sebum_cheeks?: number;
|
||||
sensitivity_level?: number;
|
||||
barrier_state?: BarrierState;
|
||||
active_concerns: SkinConcern[];
|
||||
risks: string[];
|
||||
priorities: string[];
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
id: string;
|
||||
snapshot_date: string;
|
||||
overall_state?: OverallSkinState;
|
||||
skin_type?: SkinType;
|
||||
texture?: SkinTexture;
|
||||
hydration_level?: number;
|
||||
sebum_tzone?: number;
|
||||
sebum_cheeks?: number;
|
||||
sensitivity_level?: number;
|
||||
barrier_state?: BarrierState;
|
||||
active_concerns: SkinConcern[];
|
||||
risks: string[];
|
||||
priorities: string[];
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,22 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let mobileMenuOpen = $state(false);
|
||||
|
||||
const navItems = $derived([
|
||||
{ href: '/', label: m.nav_dashboard(), icon: '🏠' },
|
||||
{ href: '/products', label: m.nav_products(), icon: '🧴' },
|
||||
{ href: '/routines', label: m.nav_routines(), icon: '📋' },
|
||||
{ href: '/routines/grooming-schedule', label: m.nav_grooming(), icon: '🪒' },
|
||||
{ href: '/health/medications', label: m.nav_medications(), icon: '💊' },
|
||||
{ href: '/health/lab-results', label: m["nav_labResults"](), icon: '🔬' },
|
||||
{ href: '/skin', label: m.nav_skin(), icon: '✨' }
|
||||
{ href: resolve('/'), label: m.nav_dashboard(), icon: '🏠' },
|
||||
{ href: resolve('/products'), label: m.nav_products(), icon: '🧴' },
|
||||
{ href: resolve('/routines'), label: m.nav_routines(), icon: '📋' },
|
||||
{ href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: '🪒' },
|
||||
{ href: resolve('/health/medications'), label: m.nav_medications(), icon: '💊' },
|
||||
{ href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: '🔬' },
|
||||
{ href: resolve('/skin'), label: m.nav_skin(), icon: '✨' }
|
||||
]);
|
||||
|
||||
function isActive(href: string) {
|
||||
|
|
@ -28,9 +31,68 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen bg-background">
|
||||
<!-- Sidebar -->
|
||||
<nav class="w-56 shrink-0 border-r border-border bg-card px-3 py-6">
|
||||
<div class="flex min-h-screen flex-col bg-background md:flex-row">
|
||||
<!-- Mobile header -->
|
||||
<header class="flex items-center justify-between border-b border-border bg-card px-4 py-3 md:hidden">
|
||||
<div>
|
||||
<span class="text-sm font-semibold tracking-tight">{m["nav_appName"]()}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{#if mobileMenuOpen}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Mobile drawer overlay -->
|
||||
{#if mobileMenuOpen}
|
||||
<!-- Backdrop: closes drawer on click -->
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-50 bg-black/50 md:hidden"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
aria-label={m.common_cancel()}
|
||||
></button>
|
||||
<!-- Drawer (same z-50 but later in DOM → on top of backdrop) -->
|
||||
<nav
|
||||
class="fixed inset-y-0 left-0 z-50 w-64 overflow-y-auto bg-card px-3 py-6 md:hidden"
|
||||
>
|
||||
<div class="mb-8 px-3">
|
||||
<h1 class="text-lg font-semibold tracking-tight">{m["nav_appName"]()}</h1>
|
||||
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p>
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
{#each navItems as item}
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors
|
||||
{isActive(item.href)
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
|
||||
>
|
||||
<span class="text-base">{item.icon}</span>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="mt-6 px-3">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<!-- Desktop Sidebar -->
|
||||
<nav class="hidden w-56 shrink-0 flex-col border-r border-border bg-card px-3 py-6 md:flex">
|
||||
<div class="mb-8 px-3">
|
||||
<h1 class="text-lg font-semibold tracking-tight">{m["nav_appName"]()}</h1>
|
||||
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p>
|
||||
|
|
@ -57,7 +119,7 @@
|
|||
</nav>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 overflow-auto p-8">
|
||||
<main class="flex-1 overflow-auto p-4 md:p-8">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { getRoutines, getSkinSnapshots } from '$lib/api';
|
||||
import type { SkinConditionSnapshot } from '$lib/types';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
|
|
@ -8,7 +9,7 @@ export const load: PageServerLoad = async () => {
|
|||
]);
|
||||
return {
|
||||
recentRoutines: routines.slice(0, 10),
|
||||
latestSnapshot: snapshots.at(-1) ?? null
|
||||
latestSnapshot: getFreshestSnapshot(snapshots)
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -17,3 +18,12 @@ function recentDate(daysAgo: number): string {
|
|||
d.setDate(d.getDate() - daysAgo);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function getFreshestSnapshot(snapshots: SkinConditionSnapshot[]): SkinConditionSnapshot | null {
|
||||
if (!snapshots.length) return null;
|
||||
return snapshots.reduce((freshest, current) => {
|
||||
if (current.snapshot_date > freshest.snapshot_date) return current;
|
||||
if (current.snapshot_date < freshest.snapshot_date) return freshest;
|
||||
return current.created_at > freshest.created_at ? current : freshest;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
|
|
@ -16,7 +17,6 @@
|
|||
TableHeader,
|
||||
TableRow
|
||||
} from '$lib/components/ui/table';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
|
|
@ -33,6 +33,12 @@
|
|||
let showForm = $state(false);
|
||||
let selectedFlag = $state('');
|
||||
let filterFlag = $derived(data.flag ?? '');
|
||||
|
||||
function onFlagChange(v: string) {
|
||||
const base = resolve('/health/lab-results');
|
||||
const url = v ? base + '?flag=' + v : base;
|
||||
goto(url, { replaceState: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head>
|
||||
|
|
@ -61,9 +67,7 @@
|
|||
<Select
|
||||
type="single"
|
||||
value={filterFlag}
|
||||
onValueChange={(v) => {
|
||||
goto(v ? `/health/lab-results?flag=${v}` : '/health/lab-results');
|
||||
}}
|
||||
onValueChange={onFlagChange}
|
||||
>
|
||||
<SelectTrigger class="w-32">{filterFlag || m["labResults_flagAll"]()}</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -79,7 +83,7 @@
|
|||
<Card>
|
||||
<CardHeader><CardTitle>{m["labResults_newTitle"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
|
||||
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<Label for="collected_at">{m["labResults_date"]()}</Label>
|
||||
<Input id="collected_at" name="collected_at" type="date" required />
|
||||
|
|
@ -125,7 +129,8 @@
|
|||
</Card>
|
||||
{/if}
|
||||
|
||||
<div class="rounded-md border border-border">
|
||||
<!-- Desktop: table -->
|
||||
<div class="hidden rounded-md border border-border md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
|
@ -173,4 +178,34 @@
|
|||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: cards -->
|
||||
<div class="flex flex-col gap-3 md:hidden">
|
||||
{#each data.results as r (r.record_id)}
|
||||
<div class="rounded-lg border border-border p-4 flex flex-col gap-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="font-medium">{r.test_name_original ?? r.test_code}</span>
|
||||
{#if r.flag}
|
||||
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {flagColors[r.flag] ?? ''}">
|
||||
{r.flag}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{r.collected_at.slice(0, 10)}</p>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="font-mono text-xs text-muted-foreground">{r.test_code}</span>
|
||||
{#if r.value_num != null}
|
||||
<span>{r.value_num} {r.unit_original ?? ''}</span>
|
||||
{:else if r.value_text}
|
||||
<span>{r.value_text}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if r.lab}
|
||||
<p class="text-xs text-muted-foreground">{r.lab}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="py-8 text-center text-sm text-muted-foreground">{m["labResults_noResults"]()}</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@
|
|||
<Card>
|
||||
<CardHeader><CardTitle>{m["medications_newTitle"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
|
||||
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1 col-span-2">
|
||||
<Label>{m.medications_kind()}</Label>
|
||||
<input type="hidden" name="kind" value={kind} />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import type { Product } from '$lib/types';
|
||||
import { resolve } from '$app/paths';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
|
@ -37,7 +39,7 @@
|
|||
return bc !== 0 ? bc : a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const map = new Map<string, Product[]>();
|
||||
const map = new SvelteMap<string, Product[]>();
|
||||
for (const p of items) {
|
||||
if (!map.has(p.category)) map.set(p.category, []);
|
||||
map.get(p.category)!.push(p);
|
||||
|
|
@ -63,10 +65,13 @@
|
|||
<h2 class="text-2xl font-bold tracking-tight">{m.products_title()}</h2>
|
||||
<p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p>
|
||||
</div>
|
||||
<Button href="/products/new">{m["products_addNew"]()}</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button href={resolve('/products/suggest')} variant="outline">✨ {m["products_suggest"]()}</Button>
|
||||
<Button href={resolve('/products/new')}>{m["products_addNew"]()}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
|
||||
<Button
|
||||
variant={ownershipFilter === f ? 'default' : 'outline'}
|
||||
|
|
@ -78,7 +83,8 @@
|
|||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-border">
|
||||
<!-- Desktop: table -->
|
||||
<div class="hidden rounded-md border border-border md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
|
@ -128,4 +134,41 @@
|
|||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: cards -->
|
||||
<div class="flex flex-col gap-3 md:hidden">
|
||||
{#if totalCount === 0}
|
||||
<p class="py-8 text-center text-sm text-muted-foreground">{m["products_noProducts"]()}</p>
|
||||
{:else}
|
||||
{#each groupedProducts as [category, products] (category)}
|
||||
<div class="border-b border-border pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{category.replace(/_/g, ' ')}
|
||||
</div>
|
||||
{#each products as product (product.id)}
|
||||
<a
|
||||
href="/products/{product.id}"
|
||||
class="block rounded-lg border border-border p-4 hover:bg-muted/50"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p class="font-medium">{product.name}</p>
|
||||
<p class="text-sm text-muted-foreground">{product.brand}</p>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs uppercase text-muted-foreground">{product.recommended_time}</span>
|
||||
</div>
|
||||
{#if product.targets.length}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each product.targets.slice(0, 3) as t (t)}
|
||||
<Badge variant="secondary" class="text-xs">{t.replace(/_/g, ' ')}</Badge>
|
||||
{/each}
|
||||
{#if product.targets.length > 3}
|
||||
<span class="text-xs text-muted-foreground">+{product.targets.length - 3}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
|
|
@ -20,10 +21,9 @@
|
|||
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="max-w-2xl space-y-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/products" class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
|
||||
<h2 class="text-2xl font-bold tracking-tight">{product.name}</h2>
|
||||
<Badge variant="outline">{product.category.replace(/_/g, ' ')}</Badge>
|
||||
<div>
|
||||
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
|
||||
<h2 class="mt-1 text-2xl font-bold tracking-tight">{product.name}</h2>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
|
|
|
|||
|
|
@ -184,9 +184,8 @@ export const actions: Actions = {
|
|||
const contextRules = parseContextRules(form);
|
||||
if (contextRules) payload.context_rules = contextRules;
|
||||
|
||||
let product;
|
||||
try {
|
||||
product = await createProduct(payload);
|
||||
await createProduct(payload);
|
||||
} catch (e) {
|
||||
return fail(500, { error: (e as Error).message });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { ActionData } from './$types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
|
@ -20,7 +21,7 @@
|
|||
|
||||
<div class="max-w-2xl space-y-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/products" class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
|
||||
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
|
||||
<h2 class="text-2xl font-bold tracking-tight">{m["products_newTitle"]()}</h2>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
25
frontend/src/routes/products/suggest/+page.server.ts
Normal file
25
frontend/src/routes/products/suggest/+page.server.ts
Normal 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;
|
||||
123
frontend/src/routes/products/suggest/+page.svelte
Normal file
123
frontend/src/routes/products/suggest/+page.svelte
Normal 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>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { resolve } from '$app/paths';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
|
@ -22,14 +23,14 @@
|
|||
<svelte:head><title>{m.routines_title()} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold tracking-tight">{m.routines_title()}</h2>
|
||||
<p class="text-muted-foreground">{m.routines_count({ count: data.routines.length })}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button href="/routines/suggest" variant="outline">{m["routines_suggestAI"]()}</Button>
|
||||
<Button href="/routines/new">{m["routines_addNew"]()}</Button>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button href={resolve('/routines/suggest')} variant="outline">{m["routines_suggestAI"]()}</Button>
|
||||
<Button href={resolve('/routines/new')}>{m["routines_addNew"]()}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
||||
import { updateRoutineStep } from '$lib/api';
|
||||
import type { GroomingAction, RoutineStep } from '$lib/types';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
|
|
@ -7,18 +10,132 @@
|
|||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectGroupHeading,
|
||||
SelectItem,
|
||||
SelectTrigger
|
||||
} from '$lib/components/ui/select';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let { routine, products } = $derived(data);
|
||||
|
||||
// ── Steps local state (synced from server data) ───────────────
|
||||
let steps = $derived([...(routine.steps ?? [])].sort((a, b) => a.order_index - b.order_index));
|
||||
|
||||
const nextOrderIndex = $derived(
|
||||
steps.length ? Math.max(...steps.map((s) => s.order_index)) + 1 : 0
|
||||
);
|
||||
|
||||
// ── Drag & drop reordering ────────────────────────────────────
|
||||
let dndSaving = $state(false);
|
||||
|
||||
function handleConsider(e: CustomEvent<DndEvent<RoutineStep>>) {
|
||||
steps = e.detail.items;
|
||||
}
|
||||
|
||||
async function handleFinalize(e: CustomEvent<DndEvent<RoutineStep>>) {
|
||||
const newItems = e.detail.items;
|
||||
// Assign new order_index = position in array, detect which changed
|
||||
const updated = newItems.map((s, i) => ({ ...s, order_index: i }));
|
||||
const changed = updated.filter((s, i) => s.order_index !== newItems[i].order_index);
|
||||
steps = updated;
|
||||
if (changed.length) {
|
||||
dndSaving = true;
|
||||
try {
|
||||
await Promise.all(
|
||||
changed.map((s) => updateRoutineStep(s.id, { order_index: s.order_index }))
|
||||
);
|
||||
} finally {
|
||||
dndSaving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inline editing ────────────────────────────────────────────
|
||||
let editingStepId = $state<string | null>(null);
|
||||
let editDraft = $state<Partial<RoutineStep>>({});
|
||||
let editSaving = $state(false);
|
||||
let editError = $state('');
|
||||
|
||||
function startEdit(step: RoutineStep) {
|
||||
editingStepId = step.id;
|
||||
editDraft = {
|
||||
dose: step.dose ?? '',
|
||||
region: step.region ?? '',
|
||||
product_id: step.product_id,
|
||||
action_type: step.action_type,
|
||||
action_notes: step.action_notes ?? ''
|
||||
};
|
||||
editError = '';
|
||||
}
|
||||
|
||||
async function saveEdit(step: RoutineStep) {
|
||||
editSaving = true;
|
||||
editError = '';
|
||||
try {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (step.product_id !== undefined) {
|
||||
payload.product_id = editDraft.product_id;
|
||||
payload.dose = editDraft.dose || null;
|
||||
payload.region = editDraft.region || null;
|
||||
} else {
|
||||
payload.action_type = editDraft.action_type;
|
||||
payload.action_notes = editDraft.action_notes || null;
|
||||
}
|
||||
const updatedStep = await updateRoutineStep(step.id, payload);
|
||||
steps = steps.map((s) => (s.id === step.id ? { ...s, ...updatedStep } : s));
|
||||
editingStepId = null;
|
||||
} catch (err) {
|
||||
editError = (err as Error).message;
|
||||
} finally {
|
||||
editSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingStepId = null;
|
||||
editDraft = {};
|
||||
editError = '';
|
||||
}
|
||||
|
||||
// ── Add step form ─────────────────────────────────────────────
|
||||
let showStepForm = $state(false);
|
||||
let selectedProductId = $state('');
|
||||
|
||||
const nextOrderIndex = $derived(
|
||||
routine.steps.length ? Math.max(...routine.steps.map((s) => s.order_index)) + 1 : 0
|
||||
);
|
||||
const GROOMING_ACTIONS: GroomingAction[] = ['shaving_razor', 'shaving_oneblade', 'dermarolling'];
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
'cleanser', 'toner', 'essence', 'serum', 'moisturizer',
|
||||
'spf', 'mask', 'exfoliant', 'spot_treatment', 'oil',
|
||||
'hair_treatment', 'tool', 'other'
|
||||
];
|
||||
|
||||
function formatCategory(cat: string): string {
|
||||
return cat.charAt(0).toUpperCase() + cat.slice(1).replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
const groupedProducts = $derived.by(() => {
|
||||
const groups = new SvelteMap<string, typeof products>();
|
||||
for (const p of products) {
|
||||
const key = p.category ?? 'other';
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(p);
|
||||
}
|
||||
for (const list of groups.values()) {
|
||||
list.sort((a, b) => {
|
||||
const b_cmp = (a.brand ?? '').localeCompare(b.brand ?? '');
|
||||
return b_cmp !== 0 ? b_cmp : a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
return CATEGORY_ORDER
|
||||
.filter((c) => groups.has(c))
|
||||
.map((c) => [c, groups.get(c)!] as const);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head>
|
||||
|
|
@ -43,7 +160,7 @@
|
|||
<!-- Steps -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">{m.routines_steps({ count: routine.steps.length })}</h3>
|
||||
<h3 class="text-lg font-semibold">{m.routines_steps({ count: steps.length })}</h3>
|
||||
<Button variant="outline" size="sm" onclick={() => (showStepForm = !showStepForm)}>
|
||||
{showStepForm ? m.common_cancel() : m["routines_addStep"]()}
|
||||
</Button>
|
||||
|
|
@ -66,8 +183,13 @@
|
|||
{/if}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each products as p (p.id)}
|
||||
<SelectItem value={p.id}>{p.name} ({p.brand})</SelectItem>
|
||||
{#each groupedProducts as [cat, items]}
|
||||
<SelectGroup>
|
||||
<SelectGroupHeading>{formatCategory(cat)}</SelectGroupHeading>
|
||||
{#each items as p (p.id)}
|
||||
<SelectItem value={p.id}>{p.name} · {p.brand}</SelectItem>
|
||||
{/each}
|
||||
</SelectGroup>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
@ -89,33 +211,148 @@
|
|||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if routine.steps.length}
|
||||
<div class="space-y-2">
|
||||
{#each routine.steps.toSorted((a, b) => a.order_index - b.order_index) as step (step.id)}
|
||||
<div class="flex items-center justify-between rounded-md border border-border px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-muted-foreground w-4">{step.order_index}</span>
|
||||
<div>
|
||||
{#if step.product_id}
|
||||
{@const product = products.find((p) => p.id === step.product_id)}
|
||||
<p class="text-sm font-medium">{product?.name ?? step.product_id}</p>
|
||||
{#if product?.brand}<p class="text-xs text-muted-foreground">{product.brand}</p>{/if}
|
||||
{:else if step.action_type}
|
||||
<p class="text-sm font-medium">{step.action_type.replace(/_/g, ' ')}</p>
|
||||
{#if steps.length}
|
||||
<div
|
||||
use:dragHandleZone={{ items: steps, flipDurationMs: 200, dragDisabled: !!editingStepId || dndSaving }}
|
||||
onconsider={handleConsider}
|
||||
onfinalize={handleFinalize}
|
||||
class="space-y-2"
|
||||
>
|
||||
{#each steps as step, i (step.id)}
|
||||
<div class="rounded-md border border-border bg-background">
|
||||
{#if editingStepId === step.id}
|
||||
<!-- ── Edit mode ── -->
|
||||
<div class="px-4 py-3 space-y-3">
|
||||
{#if step.product_id !== undefined}
|
||||
<!-- Product step: change product / dose / region -->
|
||||
<div class="space-y-1">
|
||||
<Label>{m.routines_product()}</Label>
|
||||
<Select
|
||||
type="single"
|
||||
value={editDraft.product_id ?? ''}
|
||||
onValueChange={(v) => (editDraft.product_id = v || undefined)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{#if editDraft.product_id}
|
||||
{products.find((p) => p.id === editDraft.product_id)?.name ?? m["routines_selectProduct"]()}
|
||||
{:else}
|
||||
{m["routines_selectProduct"]()}
|
||||
{/if}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each groupedProducts as [cat, items]}
|
||||
<SelectGroup>
|
||||
<SelectGroupHeading>{formatCategory(cat)}</SelectGroupHeading>
|
||||
{#each items as p (p.id)}
|
||||
<SelectItem value={p.id}>{p.name} · {p.brand}</SelectItem>
|
||||
{/each}
|
||||
</SelectGroup>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1">
|
||||
<Label>{m.routines_dose()}</Label>
|
||||
<Input
|
||||
value={editDraft.dose ?? ''}
|
||||
oninput={(e) => (editDraft.dose = e.currentTarget.value)}
|
||||
placeholder={m["routines_dosePlaceholder"]()}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label>{m.routines_region()}</Label>
|
||||
<Input
|
||||
value={editDraft.region ?? ''}
|
||||
oninput={(e) => (editDraft.region = e.currentTarget.value)}
|
||||
placeholder={m["routines_regionPlaceholder"]()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">{m["routines_unknownStep"]()}</p>
|
||||
<!-- Action step: change action_type / notes -->
|
||||
<div class="space-y-1">
|
||||
<Label>Action</Label>
|
||||
<Select
|
||||
type="single"
|
||||
value={editDraft.action_type ?? ''}
|
||||
onValueChange={(v) =>
|
||||
(editDraft.action_type = (v || undefined) as GroomingAction | undefined)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{editDraft.action_type?.replace(/_/g, ' ') ?? 'Select action'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each GROOMING_ACTIONS as action (action)}
|
||||
<SelectItem value={action}>{action.replace(/_/g, ' ')}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label>Notes</Label>
|
||||
<Input
|
||||
value={editDraft.action_notes ?? ''}
|
||||
oninput={(e) => (editDraft.action_notes = e.currentTarget.value)}
|
||||
placeholder="optional notes"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editError}
|
||||
<p class="text-sm text-destructive">{editError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" onclick={() => saveEdit(step)} disabled={editSaving}>
|
||||
{m.common_save()}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onclick={cancelEdit} disabled={editSaving}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if step.dose}
|
||||
<span class="text-xs text-muted-foreground">{step.dose}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<form method="POST" action="?/removeStep" use:enhance>
|
||||
<input type="hidden" name="step_id" value={step.id} />
|
||||
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">
|
||||
×
|
||||
</Button>
|
||||
</form>
|
||||
{:else}
|
||||
<!-- ── View mode ── -->
|
||||
<div class="flex items-center gap-1 px-2 py-3">
|
||||
<span
|
||||
use:dragHandle
|
||||
class="cursor-grab select-none px-1 text-muted-foreground/60 hover:text-muted-foreground"
|
||||
aria-label="drag to reorder"
|
||||
>⋮⋮</span>
|
||||
<span class="w-5 shrink-0 text-xs text-muted-foreground">{i + 1}.</span>
|
||||
<div class="flex-1 min-w-0 px-1">
|
||||
{#if step.product_id}
|
||||
{@const product = products.find((p) => p.id === step.product_id)}
|
||||
<p class="text-sm font-medium truncate">{product?.name ?? step.product_id}</p>
|
||||
{#if product?.brand}<p class="text-xs text-muted-foreground truncate">{product.brand}</p>{/if}
|
||||
{:else if step.action_type}
|
||||
<p class="text-sm font-medium">{step.action_type.replace(/_/g, ' ')}</p>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">{m["routines_unknownStep"]()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if step.dose}
|
||||
<span class="shrink-0 text-xs text-muted-foreground">{step.dose}</span>
|
||||
{/if}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="shrink-0 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onclick={() => startEdit(step)}
|
||||
aria-label="edit step"
|
||||
>✎</Button>
|
||||
<form method="POST" action="?/removeStep" use:enhance>
|
||||
<input type="hidden" name="step_id" value={step.id} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="shrink-0 h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||
>×</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -126,8 +363,14 @@
|
|||
|
||||
<Separator />
|
||||
|
||||
<form method="POST" action="?/delete" use:enhance
|
||||
onsubmit={(e) => { if (!confirm(m["routines_confirmDelete"]())) e.preventDefault(); }}>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance
|
||||
onsubmit={(e) => {
|
||||
if (!confirm(m["routines_confirmDelete"]())) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Button type="submit" variant="destructive" size="sm">{m["routines_deleteRoutine"]()}</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,13 +14,22 @@ export const actions: Actions = {
|
|||
const routine_date = form.get('routine_date') as string;
|
||||
const part_of_day = form.get('part_of_day') as 'am' | 'pm';
|
||||
const notes = (form.get('notes') as string) || undefined;
|
||||
const include_minoxidil_beard = form.get('include_minoxidil_beard') === 'on';
|
||||
const leaving_home =
|
||||
part_of_day === 'am' ? form.get('leaving_home') === 'on' : undefined;
|
||||
|
||||
if (!routine_date || !part_of_day) {
|
||||
return fail(400, { error: 'Data i pora dnia są wymagane.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const suggestion = await suggestRoutine({ routine_date, part_of_day, notes });
|
||||
const suggestion = await suggestRoutine({
|
||||
routine_date,
|
||||
part_of_day,
|
||||
notes,
|
||||
include_minoxidil_beard,
|
||||
leaving_home
|
||||
});
|
||||
return { suggestion, routine_date, part_of_day };
|
||||
} catch (e) {
|
||||
return fail(502, { error: (e as Error).message });
|
||||
|
|
@ -32,6 +41,8 @@ export const actions: Actions = {
|
|||
const from_date = form.get('from_date') as string;
|
||||
const to_date = form.get('to_date') as string;
|
||||
const notes = (form.get('notes') as string) || undefined;
|
||||
const include_minoxidil_beard = form.get('include_minoxidil_beard') === 'on';
|
||||
const minimize_products = form.get('minimize_products') === 'on';
|
||||
|
||||
if (!from_date || !to_date) {
|
||||
return fail(400, { error: 'Daty początkowa i końcowa są wymagane.' });
|
||||
|
|
@ -44,7 +55,7 @@ export const actions: Actions = {
|
|||
}
|
||||
|
||||
try {
|
||||
const batch = await suggestBatch({ from_date, to_date, notes });
|
||||
const batch = await suggestBatch({ from_date, to_date, notes, include_minoxidil_beard, minimize_products });
|
||||
return { batch, from_date, to_date };
|
||||
} catch (e) {
|
||||
return fail(502, { error: (e as Error).message });
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import type { PageData } from './$types';
|
||||
import type { BatchSuggestion, RoutineSuggestion, SuggestedStep } from '$lib/types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
|
|
@ -12,7 +13,7 @@
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const productMap = $derived(Object.fromEntries(data.products.map((p) => [p.id, p])));
|
||||
|
||||
|
|
@ -44,7 +45,7 @@
|
|||
function stepMeta(step: SuggestedStep): string {
|
||||
const parts: string[] = [];
|
||||
if (step.dose) parts.push(step.dose);
|
||||
if (step.region) parts.push(step.region);
|
||||
if (step.region && step.region.toLowerCase() !== 'face') parts.push(step.region);
|
||||
if (step.action_notes && !step.action_type) parts.push(step.action_notes);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
|
@ -104,7 +105,7 @@
|
|||
|
||||
<div class="max-w-2xl space-y-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/routines" class="text-sm text-muted-foreground hover:underline">{m["suggest_backToRoutines"]()}</a>
|
||||
<a href={resolve('/routines')} class="text-sm text-muted-foreground hover:underline">{m["suggest_backToRoutines"]()}</a>
|
||||
<h2 class="text-2xl font-bold tracking-tight">{m.suggest_title()}</h2>
|
||||
</div>
|
||||
|
||||
|
|
@ -152,6 +153,32 @@
|
|||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
{#if partOfDay === 'am'}
|
||||
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
|
||||
<input
|
||||
id="single_leaving_home"
|
||||
name="leaving_home"
|
||||
type="checkbox"
|
||||
class="mt-0.5 h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<div class="space-y-0.5">
|
||||
<Label for="single_leaving_home" class="font-medium">{m["suggest_leavingHomeLabel"]()}</Label>
|
||||
<p class="text-xs text-muted-foreground">{m["suggest_leavingHomeHint"]()}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
|
||||
<input
|
||||
id="single_include_minoxidil_beard"
|
||||
name="include_minoxidil_beard"
|
||||
type="checkbox"
|
||||
class="mt-0.5 h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<div class="space-y-0.5">
|
||||
<Label for="single_include_minoxidil_beard" class="font-medium">{m["suggest_minoxidilToggleLabel"]()}</Label>
|
||||
<p class="text-xs text-muted-foreground">{m["suggest_minoxidilToggleHint"]()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={loadingSingle} class="w-full">
|
||||
{#if loadingSingle}
|
||||
|
|
@ -182,6 +209,20 @@
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{#if suggestion.summary}
|
||||
<Card class="border-muted bg-muted/30">
|
||||
<CardContent class="space-y-2 pt-4">
|
||||
<p class="text-sm"><span class="font-medium">{m["suggest_summaryPrimaryGoal"]()}:</span> {suggestion.summary.primary_goal || '—'}</p>
|
||||
<p class="text-sm"><span class="font-medium">{m["suggest_summaryConfidence"]()}:</span> {Math.round((suggestion.summary.confidence ?? 0) * 100)}%</p>
|
||||
{#if suggestion.summary.constraints_applied?.length}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{m["suggest_summaryConstraints"]()}: {suggestion.summary.constraints_applied.join(' · ')}
|
||||
</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="space-y-2">
|
||||
{#each suggestion.steps as step, i (i)}
|
||||
|
|
@ -190,10 +231,18 @@
|
|||
{i + 1}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium">{stepLabel(step)}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium">{stepLabel(step)}</p>
|
||||
{#if step.optional}
|
||||
<Badge variant="secondary">{m["suggest_stepOptionalBadge"]()}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{#if stepMeta(step)}
|
||||
<p class="text-xs text-muted-foreground">{stepMeta(step)}</p>
|
||||
{/if}
|
||||
{#if step.why_this_step}
|
||||
<p class="text-xs text-muted-foreground italic">{step.why_this_step}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
|
@ -247,6 +296,30 @@
|
|||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
|
||||
<input
|
||||
id="batch_include_minoxidil_beard"
|
||||
name="include_minoxidil_beard"
|
||||
type="checkbox"
|
||||
class="mt-0.5 h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<div class="space-y-0.5">
|
||||
<Label for="batch_include_minoxidil_beard" class="font-medium">{m["suggest_minoxidilToggleLabel"]()}</Label>
|
||||
<p class="text-xs text-muted-foreground">{m["suggest_minoxidilToggleHint"]()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
|
||||
<input
|
||||
id="batch_minimize_products"
|
||||
name="minimize_products"
|
||||
type="checkbox"
|
||||
class="mt-0.5 h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<div class="space-y-0.5">
|
||||
<Label for="batch_minimize_products" class="font-medium">Minimalizuj produkty</Label>
|
||||
<p class="text-xs text-muted-foreground">Ogranicz liczbę różnych produktów</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={loadingBatch} class="w-full">
|
||||
{#if loadingBatch}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export const actions: Actions = {
|
|||
const sensitivity_level = form.get('sensitivity_level') as string;
|
||||
const barrier_state = form.get('barrier_state') as string;
|
||||
const active_concerns_raw = form.get('active_concerns') as string;
|
||||
const priorities_raw = form.get('priorities') as string;
|
||||
|
||||
if (!snapshot_date) {
|
||||
return fail(400, { error: 'Date is required' });
|
||||
|
|
@ -29,11 +30,16 @@ export const actions: Actions = {
|
|||
.map((c) => c.trim())
|
||||
.filter(Boolean) ?? [];
|
||||
|
||||
const priorities = priorities_raw
|
||||
?.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean) ?? [];
|
||||
|
||||
const skin_type = form.get('skin_type') as string;
|
||||
const sebum_tzone = form.get('sebum_tzone') as string;
|
||||
const sebum_cheeks = form.get('sebum_cheeks') as string;
|
||||
|
||||
const body: Record<string, unknown> = { snapshot_date, active_concerns };
|
||||
const body: Record<string, unknown> = { snapshot_date, active_concerns, priorities };
|
||||
if (overall_state) body.overall_state = overall_state;
|
||||
if (texture) body.texture = texture;
|
||||
if (notes) body.notes = notes;
|
||||
|
|
@ -63,6 +69,7 @@ export const actions: Actions = {
|
|||
const sensitivity_level = form.get('sensitivity_level') as string;
|
||||
const barrier_state = form.get('barrier_state') as string;
|
||||
const active_concerns_raw = form.get('active_concerns') as string;
|
||||
const priorities_raw = form.get('priorities') as string;
|
||||
const skin_type = form.get('skin_type') as string;
|
||||
const sebum_tzone = form.get('sebum_tzone') as string;
|
||||
const sebum_cheeks = form.get('sebum_cheeks') as string;
|
||||
|
|
@ -74,7 +81,12 @@ export const actions: Actions = {
|
|||
.map((c) => c.trim())
|
||||
.filter(Boolean) ?? [];
|
||||
|
||||
const body: Record<string, unknown> = { active_concerns };
|
||||
const priorities = priorities_raw
|
||||
?.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean) ?? [];
|
||||
|
||||
const body: Record<string, unknown> = { active_concerns, priorities };
|
||||
if (snapshot_date) body.snapshot_date = snapshot_date;
|
||||
if (overall_state) body.overall_state = overall_state;
|
||||
if (texture) body.texture = texture;
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@
|
|||
let sebumTzone = $state('');
|
||||
let sebumCheeks = $state('');
|
||||
let activeConcernsRaw = $state('');
|
||||
let prioritiesRaw = $state('');
|
||||
let notes = $state('');
|
||||
|
||||
// Edit state
|
||||
|
|
@ -80,6 +81,7 @@
|
|||
let editSebumTzone = $state('');
|
||||
let editSebumCheeks = $state('');
|
||||
let editActiveConcernsRaw = $state('');
|
||||
let editPrioritiesRaw = $state('');
|
||||
let editNotes = $state('');
|
||||
|
||||
function startEdit(snap: (typeof data.snapshots)[number]) {
|
||||
|
|
@ -94,6 +96,7 @@
|
|||
editSebumTzone = snap.sebum_tzone != null ? String(snap.sebum_tzone) : '';
|
||||
editSebumCheeks = snap.sebum_cheeks != null ? String(snap.sebum_cheeks) : '';
|
||||
editActiveConcernsRaw = snap.active_concerns?.join(', ') ?? '';
|
||||
editPrioritiesRaw = snap.priorities?.join(', ') ?? '';
|
||||
editNotes = snap.notes ?? '';
|
||||
showForm = false;
|
||||
}
|
||||
|
|
@ -132,6 +135,7 @@
|
|||
if (r.sebum_tzone != null) sebumTzone = String(r.sebum_tzone);
|
||||
if (r.sebum_cheeks != null) sebumCheeks = String(r.sebum_cheeks);
|
||||
if (r.active_concerns?.length) activeConcernsRaw = r.active_concerns.join(', ');
|
||||
if (r.priorities?.length) prioritiesRaw = r.priorities.join(', ');
|
||||
if (r.notes) notes = r.notes;
|
||||
aiPanelOpen = false;
|
||||
} catch (e) {
|
||||
|
|
@ -188,7 +192,7 @@
|
|||
</p>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
accept="image/heic,image/heif,image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
onchange={handleFileSelect}
|
||||
class="block w-full text-sm text-muted-foreground
|
||||
|
|
@ -220,7 +224,7 @@
|
|||
<Card>
|
||||
<CardHeader><CardTitle>{m["skin_newSnapshotTitle"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
|
||||
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<Label for="snapshot_date">{m.skin_date()}</Label>
|
||||
<Input
|
||||
|
|
@ -299,6 +303,10 @@
|
|||
<Label for="active_concerns">{m["skin_activeConcerns"]()}</Label>
|
||||
<Input id="active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={activeConcernsRaw} />
|
||||
</div>
|
||||
<div class="space-y-1 col-span-2">
|
||||
<Label for="priorities">{m["skin_priorities"]()}</Label>
|
||||
<Input id="priorities" name="priorities" placeholder={m["skin_prioritiesPlaceholder"]()} bind:value={prioritiesRaw} />
|
||||
</div>
|
||||
<div class="space-y-1 col-span-2">
|
||||
<Label for="notes">{m.skin_notes()}</Label>
|
||||
<Input id="notes" name="notes" bind:value={notes} />
|
||||
|
|
@ -324,7 +332,7 @@
|
|||
await update();
|
||||
if (result.type === 'success') editingId = null;
|
||||
}}
|
||||
class="grid grid-cols-2 gap-4"
|
||||
class="grid grid-cols-1 sm:grid-cols-2 gap-4"
|
||||
>
|
||||
<input type="hidden" name="id" value={snap.id} />
|
||||
<div class="space-y-1">
|
||||
|
|
@ -399,6 +407,10 @@
|
|||
<Label for="edit_active_concerns">{m["skin_activeConcerns"]()}</Label>
|
||||
<Input id="edit_active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={editActiveConcernsRaw} />
|
||||
</div>
|
||||
<div class="space-y-1 col-span-2">
|
||||
<Label for="edit_priorities">{m["skin_priorities"]()}</Label>
|
||||
<Input id="edit_priorities" name="priorities" placeholder={m["skin_prioritiesPlaceholder"]()} bind:value={editPrioritiesRaw} />
|
||||
</div>
|
||||
<div class="space-y-1 col-span-2">
|
||||
<Label for="edit_notes">{m.skin_notes()}</Label>
|
||||
<Input id="edit_notes" name="notes" bind:value={editNotes} />
|
||||
|
|
@ -410,27 +422,29 @@
|
|||
</form>
|
||||
{:else}
|
||||
<!-- Read view -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="font-medium">{snap.snapshot_date}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if snap.overall_state}
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[snap.overall_state] ?? ''}">
|
||||
{stateLabels[snap.overall_state]?.() ?? snap.overall_state}
|
||||
</span>
|
||||
{/if}
|
||||
{#if snap.texture}
|
||||
<Badge variant="secondary">{textureLabels[snap.texture]?.() ?? snap.texture}</Badge>
|
||||
{/if}
|
||||
<Button variant="ghost" size="sm" onclick={() => startEdit(snap)} class="h-7 px-2 text-xs">
|
||||
{m.common_edit()}
|
||||
</Button>
|
||||
<form method="POST" action="?/delete" use:enhance>
|
||||
<input type="hidden" name="id" value={snap.id} />
|
||||
<Button type="submit" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive hover:text-destructive">
|
||||
{m.common_delete()}
|
||||
</Button>
|
||||
</form>
|
||||
<div class="mb-3 space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium">{snap.snapshot_date}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onclick={() => startEdit(snap)} class="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground" aria-label={m.common_edit()}>✎</Button>
|
||||
<form method="POST" action="?/delete" use:enhance>
|
||||
<input type="hidden" name="id" value={snap.id} />
|
||||
<Button type="submit" variant="ghost" size="sm" class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive" aria-label={m.common_delete()}>×</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{#if snap.overall_state || snap.texture}
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
{#if snap.overall_state}
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[snap.overall_state] ?? ''}">
|
||||
{stateLabels[snap.overall_state]?.() ?? snap.overall_state}
|
||||
</span>
|
||||
{/if}
|
||||
{#if snap.texture}
|
||||
<Badge variant="secondary">{textureLabels[snap.texture]?.() ?? snap.texture}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3 text-sm mb-3">
|
||||
{#if snap.hydration_level != null}
|
||||
|
|
@ -459,6 +473,16 @@
|
|||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if snap.priorities?.length}
|
||||
<div class="mt-2">
|
||||
<p class="text-xs text-muted-foreground mb-1">{m["skin_prioritiesLabel"]()}</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each snap.priorities as p (p)}
|
||||
<Badge variant="outline" class="text-xs">{p}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if snap.notes}
|
||||
<p class="mt-2 text-sm text-muted-foreground">{snap.notes}</p>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ server {
|
|||
|
||||
# FastAPI backend — strip /api/ prefix
|
||||
location /api/ {
|
||||
client_max_body_size 16m; # up to 3 × 5 MB photos
|
||||
proxy_pass http://127.0.0.1:8000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
|
@ -11,18 +12,6 @@ server {
|
|||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# MCP endpoint — keep /mcp/ prefix; disable buffering for SSE streaming
|
||||
location /mcp/ {
|
||||
proxy_pass http://127.0.0.1:8000/mcp/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
# SvelteKit Node server
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue