feat(frontend): auto-generate TypeScript types from backend OpenAPI schema

Replace manually maintained types in src/lib/types.ts with auto-generated
types from FastAPI's OpenAPI schema using @hey-api/openapi-ts. The bridge
file re-exports generated types with renames, Require<> augmentations for
fields that are optional in the schema but always present in responses, and
manually added relationship fields excluded from OpenAPI.

- Add openapi-ts.config.ts and generate:api npm script
- Generate types into src/lib/api/generated/types.gen.ts
- Rewrite src/lib/types.ts as bridge with re-exports and augmentations
- Fix null vs undefined mismatches in consumer components
- Remove unused manual type definitions from api.ts
- Update AGENTS.md docs with type generation workflow
This commit is contained in:
Piotr Oleszczyk 2026-03-12 09:17:40 +01:00
parent 470d49b061
commit e29d62f949
16 changed files with 13745 additions and 2298 deletions

View file

@ -51,7 +51,7 @@ When editing frontend code, follow `docs/frontend-design-cookbook.md` and update
| Add LLM validator | `backend/innercontext/validators/` | Extend `BaseValidator`, return `ValidationResult` | | Add LLM validator | `backend/innercontext/validators/` | Extend `BaseValidator`, return `ValidationResult` |
| Add i18n strings | `frontend/messages/{en,pl}.json` | Auto-generates to `src/lib/paraglide/` | | Add i18n strings | `frontend/messages/{en,pl}.json` | Auto-generates to `src/lib/paraglide/` |
| Modify design system | `frontend/src/app.css` + `docs/frontend-design-cookbook.md` | Update both in same change | | Modify design system | `frontend/src/app.css` + `docs/frontend-design-cookbook.md` | Update both in same change |
| Modify types | `frontend/src/lib/types.ts` + `backend/innercontext/models/` | Manual sync, no codegen | | Modify types | `backend/innercontext/models/` → `pnpm generate:api``frontend/src/lib/types.ts` | Auto-generated from OpenAPI; bridge file may need augmentation |
## Commands ## Commands
@ -69,6 +69,7 @@ cd frontend && pnpm check # Type check + Svelte validation
cd frontend && pnpm lint # ESLint cd frontend && pnpm lint # ESLint
cd frontend && pnpm format # Prettier cd frontend && pnpm format # Prettier
cd frontend && pnpm build # Production build → build/ cd frontend && pnpm build # Production build → build/
cd frontend && pnpm generate:api # Regenerate types from backend OpenAPI
``` ```
## Commit Guidelines ## Commit Guidelines
@ -83,7 +84,7 @@ Conventional Commits: `feat(api): ...`, `fix(frontend): ...`, `test(models): ...
### Cross-Cutting Patterns ### Cross-Cutting Patterns
- **Type sharing**: Manual sync between `frontend/src/lib/types.ts` and `backend/innercontext/models/`. No code generation. - **Type sharing**: Auto-generated from backend OpenAPI schema via `@hey-api/openapi-ts`. Run `cd frontend && pnpm generate:api` after backend model changes. `src/lib/types.ts` is a bridge file with re-exports, renames, and `Require<>` augmentations. See `frontend/AGENTS.md` § Type Generation.
- **API proxy**: Frontend server-side uses `PUBLIC_API_BASE` (http://localhost:8000). Browser uses `/api` (nginx strips prefix → backend). - **API proxy**: Frontend server-side uses `PUBLIC_API_BASE` (http://localhost:8000). Browser uses `/api` (nginx strips prefix → backend).
- **Auth**: None. Single-user personal system. - **Auth**: None. Single-user personal system.
- **Error flow**: Backend `HTTPException(detail=...)` → Frontend catches `.detail` field → `FlashMessages` or `StructuredErrorDisplay`. - **Error flow**: Backend `HTTPException(detail=...)` → Frontend catches `.detail` field → `FlashMessages` or `StructuredErrorDisplay`.

View file

@ -1,6 +1,8 @@
node_modules node_modules
.svelte-kit .svelte-kit
paraglide paraglide
src/lib/api/generated
openapi.json
build build
dist dist
.env .env

View file

@ -19,7 +19,8 @@ frontend/src/
│ └── profile/ # User profile │ └── profile/ # User profile
└── lib/ └── lib/
├── api.ts # Typed fetch wrappers (server: PUBLIC_API_BASE, browser: /api) ├── api.ts # Typed fetch wrappers (server: PUBLIC_API_BASE, browser: /api)
├── types.ts # TypeScript types mirroring backend models (manual sync) ├── types.ts # Type bridge — re-exports from generated OpenAPI types with augmentations
├── api/generated/ # Auto-generated types from backend OpenAPI schema — DO NOT EDIT
├── utils.ts # cn() class merger, bits-ui types ├── utils.ts # cn() class merger, bits-ui types
├── utils/ # forms.ts (preventIfNotConfirmed), skin-display.ts (label helpers) ├── utils/ # forms.ts (preventIfNotConfirmed), skin-display.ts (label helpers)
├── paraglide/ # Generated i18n runtime — DO NOT EDIT ├── paraglide/ # Generated i18n runtime — DO NOT EDIT
@ -123,6 +124,7 @@ pnpm check # Type check + Svelte validation
pnpm lint # ESLint pnpm lint # ESLint
pnpm format # Prettier pnpm format # Prettier
pnpm build # Production build → build/ pnpm build # Production build → build/
pnpm generate:api # Regenerate TypeScript types from backend OpenAPI schema
``` ```
## Anti-Patterns ## Anti-Patterns
@ -130,3 +132,29 @@ pnpm build # Production build → build/
- No frontend tests exist. Only linting + type checking. - No frontend tests exist. Only linting + type checking.
- ESLint `svelte/no-navigation-without-resolve` has `ignoreGoto: true` workaround (upstream bug sveltejs/eslint-plugin-svelte#1327). - ESLint `svelte/no-navigation-without-resolve` has `ignoreGoto: true` workaround (upstream bug sveltejs/eslint-plugin-svelte#1327).
- `src/paraglide/` is a legacy output path — active i18n output is in `src/lib/paraglide/`. - `src/paraglide/` is a legacy output path — active i18n output is in `src/lib/paraglide/`.
## Type Generation
TypeScript types are auto-generated from the FastAPI backend's OpenAPI schema using `@hey-api/openapi-ts`.
### Workflow
1. Generate `openapi.json` from backend: `cd backend && uv run python -c "import json; from main import app; print(json.dumps(app.openapi(), indent=2))" > ../frontend/openapi.json`
2. Generate types: `cd frontend && pnpm generate:api`
3. Output lands in `src/lib/api/generated/types.gen.ts`**never edit this file directly**.
### Architecture
- **Generated types**: `src/lib/api/generated/types.gen.ts` — raw OpenAPI types, auto-generated.
- **Bridge file**: `src/lib/types.ts` — re-exports from generated types with:
- **Renames**: `ProductWithInventory``Product`, `ProductListItem``ProductSummary`, `UserProfilePublic``UserProfile`, `SkinConditionSnapshotPublic``SkinConditionSnapshot`.
- **`Require<T, K>` augmentations**: Fields with `default_factory` in SQLModel are optional in OpenAPI but always present in API responses (e.g. `id`, `created_at`, `updated_at`, `targets`, `inventory`).
- **Relationship fields**: SQLModel `Relationship()` fields are excluded from OpenAPI schema. Added manually: `MedicationEntry.usage_history`, `Routine.steps`, `RoutineStep.product`, `ProductInventory.product`, `Product.inventory` (with augmented `ProductInventory`).
- **Manual types**: `PriceTierSource`, `ShoppingPriority` — inline literals in backend, not named in OpenAPI.
- **Canonical import**: Always `import type { ... } from '$lib/types'` — never import from `$lib/api/generated` directly.
### When to regenerate
- After adding/modifying backend models or response schemas.
- After adding/modifying API endpoints that change the OpenAPI spec.
- After updating the bridge file, run `pnpm check` to verify type compatibility.

View file

@ -69,6 +69,6 @@ Or use the provided systemd service: `../systemd/innercontext-node.service`.
| File | Purpose | | File | Purpose |
| ------------------ | --------------------------------- | | ------------------ | --------------------------------- |
| `src/lib/api.ts` | API client (typed fetch wrappers) | | `src/lib/api.ts` | API client (typed fetch wrappers) |
| `src/lib/types.ts` | Shared TypeScript types | | `src/lib/types.ts` | Type bridge (re-exports from generated OpenAPI types) |
| `src/app.css` | Tailwind v4 theme + global styles | | `src/app.css` | Tailwind v4 theme + global styles |
| `svelte.config.js` | SvelteKit config (adapter-node) | | `svelte.config.js` | SvelteKit config (adapter-node) |

View file

@ -12,6 +12,7 @@ export default [
"dist", "dist",
"**/paraglide/**", "**/paraglide/**",
"**/lib/paraglide/**", "**/lib/paraglide/**",
"**/api/generated/**",
], ],
}, },
js.configs.recommended, js.configs.recommended,

View file

@ -0,0 +1,14 @@
import { defineConfig } from "@hey-api/openapi-ts";
export default defineConfig({
input: "./openapi.json",
output: {
path: "src/lib/api/generated",
},
plugins: [
{
name: "@hey-api/typescript",
enums: false, // union types, matching existing frontend pattern
},
],
});

8353
frontend/openapi.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -11,10 +11,12 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --write ." "format": "prettier --write .",
"generate:api": "cd ../backend && uv run python -c \"import json; from main import app; print(json.dumps(app.openapi(), indent=2))\" > ../frontend/openapi.json && cd ../frontend && openapi-ts"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@hey-api/openapi-ts": "^0.94.0",
"@internationalized/date": "^3.11.0", "@internationalized/date": "^3.11.0",
"@lucide/svelte": "^0.561.0", "@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-node": "^5.0.0", "@sveltejs/adapter-node": "^5.0.0",

3078
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,22 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { PUBLIC_API_BASE } from "$env/static/public"; import { PUBLIC_API_BASE } from "$env/static/public";
import type { import type {
ActiveIngredient,
BatchSuggestion, BatchSuggestion,
GroomingSchedule, GroomingSchedule,
LabResult, LabResult,
LabResultListResponse,
MedicationEntry, MedicationEntry,
MedicationUsage, MedicationUsage,
PartOfDay, PartOfDay,
Product, Product,
ProductSummary,
ProductContext,
ProductEffectProfile,
ProductInventory, ProductInventory,
ProductParseResponse,
ProductSummary,
Routine, Routine,
RoutineSuggestion, RoutineSuggestion,
RoutineStep, RoutineStep,
SkinConditionSnapshot, SkinConditionSnapshot,
SkinPhotoAnalysisResponse,
UserProfile, UserProfile,
} from "./types"; } from "./types";
@ -120,41 +120,6 @@ export const updateInventory = (
export const deleteInventory = (id: string): Promise<void> => export const deleteInventory = (id: string): Promise<void> =>
api.del(`/inventory/${id}`); 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_amount?: number;
price_currency?: string;
size_ml?: number;
pao_months?: number;
inci?: string[];
actives?: ActiveIngredient[];
recommended_for?: string[];
targets?: 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;
context_rules?: ProductContext;
min_interval_hours?: number;
max_frequency_per_week?: number;
is_medication?: boolean;
is_tool?: boolean;
needle_length_mm?: number;
}
export const parseProductText = (text: string): Promise<ProductParseResponse> => export const parseProductText = (text: string): Promise<ProductParseResponse> =>
api.post("/products/parse-text", { text }); api.post("/products/parse-text", { text });
@ -283,13 +248,6 @@ export interface LabResultListParams {
offset?: number; offset?: number;
} }
export interface LabResultListResponse {
items: LabResult[];
total: number;
limit: number;
offset: number;
}
export function getLabResults( export function getLabResults(
params: LabResultListParams = {}, params: LabResultListParams = {},
): Promise<LabResultListResponse> { ): Promise<LabResultListResponse> {
@ -353,21 +311,6 @@ export const updateSkinSnapshot = (
export const deleteSkinSnapshot = (id: string): Promise<void> => export const deleteSkinSnapshot = (id: string): Promise<void> =>
api.del(`/skincare/${id}`); 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;
}
export async function analyzeSkinPhotos( export async function analyzeSkinPhotos(
files: File[], files: File[],
): Promise<SkinPhotoAnalysisResponse> { ): Promise<SkinPhotoAnalysisResponse> {

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,12 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import type { Product } from '$lib/types'; import type { Product, IngredientFunction, ProductParseResponse } from '$lib/types';
import type { IngredientFunction } from '$lib/types';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { Tabs, TabsList, TabsTrigger } from '$lib/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { baseSelectClass, baseTextareaClass } from '$lib/components/forms/form-classes'; import { baseSelectClass, baseTextareaClass } from '$lib/components/forms/form-classes';
import type { ProductParseResponse } from '$lib/api';
import * as m from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { Sparkles, X } from 'lucide-svelte'; import { Sparkles, X } from 'lucide-svelte';

View file

@ -1,390 +1,112 @@
// ─── Enums ────────────────────────────────────────────────────────────────── // Re-exports from generated OpenAPI types.
// This bridge file keeps `$lib/types` as the canonical import path while
// renaming backend response-model names to shorter frontend-friendly names
// and augmenting types where the OpenAPI schema diverges from runtime:
// - Relationship fields (SQLModel Relationships excluded from schema)
// - Default-valued fields marked optional in schema but always present in responses
export type AbsorptionSpeed = import type {
| "very_fast" GroomingSchedule as _GroomingSchedule,
| "fast" LabResult as _LabResult,
| "moderate" LabResultListResponse as _LabResultListResponse,
| "slow" MedicationEntry as _MedicationEntry,
| "very_slow"; MedicationUsage as _MedicationUsage,
export type BarrierState = "intact" | "mildly_compromised" | "compromised"; ProductInventory as _ProductInventory,
export type DayTime = "am" | "pm" | "both"; ProductListItem as _ProductListItem,
export type GroomingAction = ProductWithInventory as _ProductWithInventory,
| "shaving_razor" Routine as _Routine,
| "shaving_oneblade" RoutineStep as _RoutineStep,
| "dermarolling"; SkinConditionSnapshotPublic as _SkinConditionSnapshotPublic,
export type IngredientFunction = } from "./api/generated/types.gen";
| "humectant"
| "emollient" type Require<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
| "occlusive"
| "exfoliant_aha" export type {
| "exfoliant_bha" AbsorptionSpeed,
| "exfoliant_pha" ActiveIngredient,
| "retinoid" BarrierState,
| "antioxidant" BatchSuggestion,
| "soothing" DayPlan,
| "barrier_support" DayTime,
| "brightening" GroomingAction,
| "anti_acne" IngredientFunction,
| "ceramide" MedicationKind,
| "niacinamide" OverallSkinState,
| "sunscreen" PartOfDay,
| "peptide" PriceTier,
| "hair_growth_stimulant" ProductCategory,
| "prebiotic" ProductContext,
| "vitamin_c" ProductEffectProfile,
| "anti_aging"; ProductParseResponse,
export type MedicationKind = ProductSuggestion,
| "prescription" RemainingLevel,
| "otc" ResponseMetadata,
| "supplement" ResultFlag,
| "herbal" RoutineSuggestion,
| "other"; RoutineSuggestionSummary,
export type OverallSkinState = "excellent" | "good" | "fair" | "poor"; SexAtBirth,
export type PartOfDay = "am" | "pm"; ShoppingSuggestionResponse,
export type PriceTier = "budget" | "mid" | "premium" | "luxury"; SkinConcern,
SkinPhotoAnalysisResponse,
SkinTexture,
SkinType,
StrengthLevel,
SuggestedStep,
TextureType,
TokenMetrics,
} from "./api/generated/types.gen";
export type { ProductWithInventory } from "./api/generated/types.gen";
export type { UserProfilePublic as UserProfile } from "./api/generated/types.gen";
// Inline literal types in backend — not named schemas in OpenAPI
export type PriceTierSource = "category" | "fallback" | "insufficient_data"; export type PriceTierSource = "category" | "fallback" | "insufficient_data";
export type RemainingLevel = "high" | "medium" | "low" | "nearly_empty"; export type ShoppingPriority = "high" | "medium" | "low";
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";
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 SexAtBirth = "male" | "female" | "intersex";
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";
// ─── Product types ───────────────────────────────────────────────────────────
export interface ActiveIngredient { // Response types: required fields + relationship augmentations.
name: string;
percent?: number;
functions: IngredientFunction[];
strength_level?: StrengthLevel;
irritation_potential?: StrengthLevel;
}
export interface ProductEffectProfile { export type Product = Require<
hydration_immediate: number; _ProductWithInventory,
hydration_long_term: number; "id" | "created_at" | "updated_at" | "inventory" | "inci" | "recommended_for" | "targets"
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 ProductContext {
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;
remaining_level?: RemainingLevel;
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_amount?: number;
price_currency?: string;
price_tier?: PriceTier;
price_per_use_pln?: number;
price_tier_source?: PriceTierSource;
size_ml?: number;
pao_months?: number;
inci: string[];
actives?: ActiveIngredient[];
recommended_for: SkinType[];
targets: SkinConcern[];
fragrance_free?: boolean;
essential_oils_free?: boolean;
alcohol_denat_free?: boolean;
pregnancy_safe?: boolean;
product_effect_profile: ProductEffectProfile;
ph_min?: number;
ph_max?: number;
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;
created_at: string;
updated_at: string;
inventory: ProductInventory[]; inventory: ProductInventory[];
} };
export interface ProductSummary { export type ProductSummary = Require<_ProductListItem, "id" | "targets">;
id: string;
name: string;
brand: string;
category: ProductCategory;
recommended_time: DayTime;
targets: SkinConcern[];
is_owned: boolean;
price_tier?: PriceTier;
price_per_use_pln?: number;
price_tier_source?: PriceTierSource;
}
// ─── Routine types ─────────────────────────────────────────────────────────── export type SkinConditionSnapshot = Require<
_SkinConditionSnapshotPublic,
"id" | "created_at" | "active_concerns" | "risks" | "priorities"
>;
export interface RoutineStep { export type GroomingSchedule = Require<_GroomingSchedule, "id">;
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 { export type LabResult = Require<_LabResult, "record_id" | "created_at" | "updated_at">;
id: string;
routine_date: string;
part_of_day: PartOfDay;
notes?: string;
created_at: string;
updated_at: string;
steps?: RoutineStep[];
}
export interface GroomingSchedule { export type LabResultListResponse = Omit<_LabResultListResponse, "items"> & {
id: string; items: LabResult[];
day_of_week: number; };
action: GroomingAction;
notes?: string;
}
export interface SuggestedStep { export type MedicationUsage = Require<
product_id?: string; _MedicationUsage,
action_type?: GroomingAction; "record_id" | "created_at" | "updated_at"
action_notes?: string; >;
region?: string;
why_this_step?: string;
optional?: boolean;
}
export interface RoutineSuggestionSummary { export type MedicationEntry = Require<
primary_goal: string; _MedicationEntry,
constraints_applied: string[]; "record_id" | "created_at" | "updated_at"
confidence: number; > & {
}
// Phase 3: Observability metadata types
export interface TokenMetrics {
prompt_tokens: number;
completion_tokens: number;
thoughts_tokens?: number;
total_tokens: number;
}
export interface ResponseMetadata {
model_used: string;
duration_ms: number;
reasoning_chain?: string;
token_metrics?: TokenMetrics;
}
export interface RoutineSuggestion {
steps: SuggestedStep[];
reasoning: string;
summary?: RoutineSuggestionSummary;
// Phase 3: Observability fields
validation_warnings?: string[];
auto_fixes_applied?: string[];
response_metadata?: ResponseMetadata;
}
export interface DayPlan {
date: string;
am_steps: SuggestedStep[];
pm_steps: SuggestedStep[];
reasoning: string;
}
export interface BatchSuggestion {
days: DayPlan[];
overall_reasoning: string;
// Phase 3: Observability fields
validation_warnings?: string[];
auto_fixes_applied?: string[];
response_metadata?: ResponseMetadata;
}
// ─── Shopping suggestion types ───────────────────────────────────────────────
export type ShoppingPriority = 'high' | 'medium' | 'low';
export interface ProductSuggestion {
category: string;
product_type: string;
priority: ShoppingPriority;
key_ingredients: string[];
target_concerns: string[];
recommended_time: string;
frequency: string;
short_reason: string;
reason_to_buy_now: string;
reason_not_needed_if_budget_tight?: string;
fit_with_current_routine: string;
usage_cautions: string[];
}
export interface ShoppingSuggestionResponse {
suggestions: ProductSuggestion[];
reasoning: string;
// Phase 3: Observability fields
validation_warnings?: string[];
auto_fixes_applied?: string[];
response_metadata?: ResponseMetadata;
}
// ─── 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;
}
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[]; usage_history: MedicationUsage[];
} };
export interface LabResult { export type ProductInventory = Require<_ProductInventory, "id" | "created_at"> & {
record_id: string; product?: _ProductWithInventory;
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 type Routine = Require<_Routine, "id" | "created_at" | "updated_at"> & {
steps?: RoutineStep[];
};
export interface SkinConditionSnapshot { export type RoutineStep = Require<_RoutineStep, "id"> & {
id: string; product?: _ProductWithInventory;
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;
}
export interface UserProfile {
id: string;
birth_date?: string;
sex_at_birth?: SexAtBirth;
created_at: string;
updated_at: string;
}

View file

@ -49,7 +49,7 @@
return a.localeCompare(b, undefined, { sensitivity: 'base' }); return a.localeCompare(b, undefined, { sensitivity: 'base' });
} }
function comparePrice(a?: number, b?: number): number { function comparePrice(a?: number | null, b?: number | null): number {
if (a == null && b == null) return 0; if (a == null && b == null) return 0;
if (a == null) return 1; if (a == null) return 1;
if (b == null) return -1; if (b == null) return -1;
@ -109,12 +109,12 @@
const totalCount = $derived(groupedProducts.reduce((s, [, arr]) => s + arr.length, 0)); const totalCount = $derived(groupedProducts.reduce((s, [, arr]) => s + arr.length, 0));
const ownedCount = $derived(data.products.filter((product) => product.is_owned).length); const ownedCount = $derived(data.products.filter((product) => product.is_owned).length);
function formatPricePerUse(value?: number): string { function formatPricePerUse(value?: number | null): string {
if (value == null) return '-'; if (value == null) return '-';
return `${value.toFixed(2)} ${m.common_pricePerUse()}`; return `${value.toFixed(2)} ${m.common_pricePerUse()}`;
} }
function formatTier(value?: string): string { function formatTier(value?: string | null): string {
if (!value) return m.common_unknown(); if (!value) return m.common_unknown();
if (value === 'budget') return m['productForm_priceBudget'](); if (value === 'budget') return m['productForm_priceBudget']();
if (value === 'mid') return m['productForm_priceMid'](); if (value === 'mid') return m['productForm_priceMid']();
@ -123,7 +123,7 @@
return value; return value;
} }
function getPricePerUse(product: ProductSummary): number | undefined { function getPricePerUse(product: ProductSummary): number | null | undefined {
return product.price_per_use_pln; return product.price_per_use_pln;
} }

View file

@ -65,7 +65,7 @@
return (product as { price_per_use_pln?: number }).price_per_use_pln; return (product as { price_per_use_pln?: number }).price_per_use_pln;
} }
function formatTier(value?: string): string { function formatTier(value?: string | null): string {
if (!value) return m.common_unknown(); if (!value) return m.common_unknown();
if (value === 'budget') return m['productForm_priceBudget'](); if (value === 'budget') return m['productForm_priceBudget']();
if (value === 'mid') return m['productForm_priceMid'](); if (value === 'mid') return m['productForm_priceMid']();
@ -248,7 +248,7 @@
editingInventoryId = null; editingInventoryId = null;
} else { } else {
editingInventoryId = pkg.id; editingInventoryId = pkg.id;
editingInventoryOpened = { ...editingInventoryOpened, [pkg.id]: pkg.is_opened }; editingInventoryOpened = { ...editingInventoryOpened, [pkg.id]: pkg.is_opened ?? false };
} }
}} }}
> >