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 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`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export default [
|
||||||
"dist",
|
"dist",
|
||||||
"**/paraglide/**",
|
"**/paraglide/**",
|
||||||
"**/lib/paraglide/**",
|
"**/lib/paraglide/**",
|
||||||
|
"**/api/generated/**",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
js.configs.recommended,
|
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": "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
3078
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 { 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> {
|
||||||
|
|
|
||||||
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">
|
<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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue