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:
parent
470d49b061
commit
e29d62f949
16 changed files with 13745 additions and 2298 deletions
|
|
@ -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 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 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
|
||||
|
||||
|
|
@ -69,6 +69,7 @@ cd frontend && pnpm check # Type check + Svelte validation
|
|||
cd frontend && pnpm lint # ESLint
|
||||
cd frontend && pnpm format # Prettier
|
||||
cd frontend && pnpm build # Production build → build/
|
||||
cd frontend && pnpm generate:api # Regenerate types from backend OpenAPI
|
||||
```
|
||||
|
||||
## Commit Guidelines
|
||||
|
|
@ -83,7 +84,7 @@ Conventional Commits: `feat(api): ...`, `fix(frontend): ...`, `test(models): ...
|
|||
|
||||
### 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).
|
||||
- **Auth**: None. Single-user personal system.
|
||||
- **Error flow**: Backend `HTTPException(detail=...)` → Frontend catches `.detail` field → `FlashMessages` or `StructuredErrorDisplay`.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
node_modules
|
||||
.svelte-kit
|
||||
paraglide
|
||||
src/lib/api/generated
|
||||
openapi.json
|
||||
build
|
||||
dist
|
||||
.env
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ frontend/src/
|
|||
│ └── profile/ # User profile
|
||||
└── lib/
|
||||
├── 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/ # forms.ts (preventIfNotConfirmed), skin-display.ts (label helpers)
|
||||
├── paraglide/ # Generated i18n runtime — DO NOT EDIT
|
||||
|
|
@ -123,6 +124,7 @@ pnpm check # Type check + Svelte validation
|
|||
pnpm lint # ESLint
|
||||
pnpm format # Prettier
|
||||
pnpm build # Production build → build/
|
||||
pnpm generate:api # Regenerate TypeScript types from backend OpenAPI schema
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
|
@ -130,3 +132,29 @@ pnpm build # Production build → build/
|
|||
- 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).
|
||||
- `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.
|
||||
|
|
|
|||
|
|
@ -69,6 +69,6 @@ Or use the provided systemd service: `../systemd/innercontext-node.service`.
|
|||
| File | Purpose |
|
||||
| ------------------ | --------------------------------- |
|
||||
| `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 |
|
||||
| `svelte.config.js` | SvelteKit config (adapter-node) |
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export default [
|
|||
"dist",
|
||||
"**/paraglide/**",
|
||||
"**/lib/paraglide/**",
|
||||
"**/api/generated/**",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
|
|
|
|||
14
frontend/openapi-ts.config.ts
Normal file
14
frontend/openapi-ts.config.ts
Normal 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
8353
frontend/openapi.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -11,10 +11,12 @@
|
|||
"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 ."
|
||||
"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": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@hey-api/openapi-ts": "^0.94.0",
|
||||
"@internationalized/date": "^3.11.0",
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
|
|
|
|||
3156
frontend/pnpm-lock.yaml
generated
3156
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,22 +1,22 @@
|
|||
import { browser } from "$app/environment";
|
||||
import { PUBLIC_API_BASE } from "$env/static/public";
|
||||
import type {
|
||||
ActiveIngredient,
|
||||
BatchSuggestion,
|
||||
GroomingSchedule,
|
||||
LabResult,
|
||||
LabResultListResponse,
|
||||
MedicationEntry,
|
||||
MedicationUsage,
|
||||
PartOfDay,
|
||||
Product,
|
||||
ProductSummary,
|
||||
ProductContext,
|
||||
ProductEffectProfile,
|
||||
ProductInventory,
|
||||
ProductParseResponse,
|
||||
ProductSummary,
|
||||
Routine,
|
||||
RoutineSuggestion,
|
||||
RoutineStep,
|
||||
SkinConditionSnapshot,
|
||||
SkinPhotoAnalysisResponse,
|
||||
UserProfile,
|
||||
} from "./types";
|
||||
|
||||
|
|
@ -120,41 +120,6 @@ export const updateInventory = (
|
|||
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_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> =>
|
||||
api.post("/products/parse-text", { text });
|
||||
|
||||
|
|
@ -283,13 +248,6 @@ export interface LabResultListParams {
|
|||
offset?: number;
|
||||
}
|
||||
|
||||
export interface LabResultListResponse {
|
||||
items: LabResult[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export function getLabResults(
|
||||
params: LabResultListParams = {},
|
||||
): Promise<LabResultListResponse> {
|
||||
|
|
@ -353,21 +311,6 @@ export const updateSkinSnapshot = (
|
|||
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;
|
||||
}
|
||||
|
||||
export async function analyzeSkinPhotos(
|
||||
files: File[],
|
||||
): Promise<SkinPhotoAnalysisResponse> {
|
||||
|
|
|
|||
3
frontend/src/lib/api/generated/index.ts
Normal file
3
frontend/src/lib/api/generated/index.ts
Normal file
File diff suppressed because one or more lines are too long
3922
frontend/src/lib/api/generated/types.gen.ts
Normal file
3922
frontend/src/lib/api/generated/types.gen.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,14 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { Product } from '$lib/types';
|
||||
import type { IngredientFunction } from '$lib/types';
|
||||
import type { Product, IngredientFunction, ProductParseResponse } from '$lib/types';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Tabs, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { baseSelectClass, baseTextareaClass } from '$lib/components/forms/form-classes';
|
||||
import type { ProductParseResponse } from '$lib/api';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { Sparkles, X } from 'lucide-svelte';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
| "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 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";
|
||||
import type {
|
||||
GroomingSchedule as _GroomingSchedule,
|
||||
LabResult as _LabResult,
|
||||
LabResultListResponse as _LabResultListResponse,
|
||||
MedicationEntry as _MedicationEntry,
|
||||
MedicationUsage as _MedicationUsage,
|
||||
ProductInventory as _ProductInventory,
|
||||
ProductListItem as _ProductListItem,
|
||||
ProductWithInventory as _ProductWithInventory,
|
||||
Routine as _Routine,
|
||||
RoutineStep as _RoutineStep,
|
||||
SkinConditionSnapshotPublic as _SkinConditionSnapshotPublic,
|
||||
} from "./api/generated/types.gen";
|
||||
|
||||
type Require<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
|
||||
|
||||
export type {
|
||||
AbsorptionSpeed,
|
||||
ActiveIngredient,
|
||||
BarrierState,
|
||||
BatchSuggestion,
|
||||
DayPlan,
|
||||
DayTime,
|
||||
GroomingAction,
|
||||
IngredientFunction,
|
||||
MedicationKind,
|
||||
OverallSkinState,
|
||||
PartOfDay,
|
||||
PriceTier,
|
||||
ProductCategory,
|
||||
ProductContext,
|
||||
ProductEffectProfile,
|
||||
ProductParseResponse,
|
||||
ProductSuggestion,
|
||||
RemainingLevel,
|
||||
ResponseMetadata,
|
||||
ResultFlag,
|
||||
RoutineSuggestion,
|
||||
RoutineSuggestionSummary,
|
||||
SexAtBirth,
|
||||
ShoppingSuggestionResponse,
|
||||
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 RemainingLevel = "high" | "medium" | "low" | "nearly_empty";
|
||||
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 type ShoppingPriority = "high" | "medium" | "low";
|
||||
|
||||
export interface ActiveIngredient {
|
||||
name: string;
|
||||
percent?: number;
|
||||
functions: IngredientFunction[];
|
||||
strength_level?: StrengthLevel;
|
||||
irritation_potential?: StrengthLevel;
|
||||
}
|
||||
// Response types: required fields + relationship augmentations.
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
export type Product = Require<
|
||||
_ProductWithInventory,
|
||||
"id" | "created_at" | "updated_at" | "inventory" | "inci" | "recommended_for" | "targets"
|
||||
> & {
|
||||
inventory: ProductInventory[];
|
||||
}
|
||||
};
|
||||
|
||||
export interface ProductSummary {
|
||||
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;
|
||||
}
|
||||
export type ProductSummary = Require<_ProductListItem, "id" | "targets">;
|
||||
|
||||
// ─── Routine types ───────────────────────────────────────────────────────────
|
||||
export type SkinConditionSnapshot = Require<
|
||||
_SkinConditionSnapshotPublic,
|
||||
"id" | "created_at" | "active_concerns" | "risks" | "priorities"
|
||||
>;
|
||||
|
||||
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;
|
||||
}
|
||||
export type GroomingSchedule = Require<_GroomingSchedule, "id">;
|
||||
|
||||
export interface Routine {
|
||||
id: string;
|
||||
routine_date: string;
|
||||
part_of_day: PartOfDay;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
steps?: RoutineStep[];
|
||||
}
|
||||
export type LabResult = Require<_LabResult, "record_id" | "created_at" | "updated_at">;
|
||||
|
||||
export interface GroomingSchedule {
|
||||
id: string;
|
||||
day_of_week: number;
|
||||
action: GroomingAction;
|
||||
notes?: string;
|
||||
}
|
||||
export type LabResultListResponse = Omit<_LabResultListResponse, "items"> & {
|
||||
items: LabResult[];
|
||||
};
|
||||
|
||||
export interface SuggestedStep {
|
||||
product_id?: string;
|
||||
action_type?: GroomingAction;
|
||||
action_notes?: string;
|
||||
region?: string;
|
||||
why_this_step?: string;
|
||||
optional?: boolean;
|
||||
}
|
||||
export type MedicationUsage = Require<
|
||||
_MedicationUsage,
|
||||
"record_id" | "created_at" | "updated_at"
|
||||
>;
|
||||
|
||||
export interface RoutineSuggestionSummary {
|
||||
primary_goal: string;
|
||||
constraints_applied: string[];
|
||||
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;
|
||||
export type MedicationEntry = Require<
|
||||
_MedicationEntry,
|
||||
"record_id" | "created_at" | "updated_at"
|
||||
> & {
|
||||
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;
|
||||
}
|
||||
export type ProductInventory = Require<_ProductInventory, "id" | "created_at"> & {
|
||||
product?: _ProductWithInventory;
|
||||
};
|
||||
|
||||
// ─── Skin types ──────────────────────────────────────────────────────────────
|
||||
export type Routine = Require<_Routine, "id" | "created_at" | "updated_at"> & {
|
||||
steps?: RoutineStep[];
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
birth_date?: string;
|
||||
sex_at_birth?: SexAtBirth;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
export type RoutineStep = Require<_RoutineStep, "id"> & {
|
||||
product?: _ProductWithInventory;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
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) return 1;
|
||||
if (b == null) return -1;
|
||||
|
|
@ -109,12 +109,12 @@
|
|||
const totalCount = $derived(groupedProducts.reduce((s, [, arr]) => s + arr.length, 0));
|
||||
const ownedCount = $derived(data.products.filter((product) => product.is_owned).length);
|
||||
|
||||
function formatPricePerUse(value?: number): string {
|
||||
function formatPricePerUse(value?: number | null): string {
|
||||
if (value == null) return '-';
|
||||
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 === 'budget') return m['productForm_priceBudget']();
|
||||
if (value === 'mid') return m['productForm_priceMid']();
|
||||
|
|
@ -123,7 +123,7 @@
|
|||
return value;
|
||||
}
|
||||
|
||||
function getPricePerUse(product: ProductSummary): number | undefined {
|
||||
function getPricePerUse(product: ProductSummary): number | null | undefined {
|
||||
return product.price_per_use_pln;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@
|
|||
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 === 'budget') return m['productForm_priceBudget']();
|
||||
if (value === 'mid') return m['productForm_priceMid']();
|
||||
|
|
@ -248,7 +248,7 @@
|
|||
editingInventoryId = null;
|
||||
} else {
|
||||
editingInventoryId = pkg.id;
|
||||
editingInventoryOpened = { ...editingInventoryOpened, [pkg.id]: pkg.is_opened };
|
||||
editingInventoryOpened = { ...editingInventoryOpened, [pkg.id]: pkg.is_opened ?? false };
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue