Compare commits

..

No commits in common. "b08561c9bbc23a5a30aaa6ea57a72775a985102c" and "211489ca5544ae3ac28b374466f39c124f095192" have entirely different histories.

8 changed files with 133 additions and 357 deletions

View file

@ -98,15 +98,3 @@ Environment
Testing Testing
- Unit tests use MSW; the client defaults to a localhost base in tests for easy mocking - Unit tests use MSW; the client defaults to a localhost base in tests for easy mocking
### CurrentShoppingList item kinds
The OpenAPI spec models current shopping list items as distinct kinds:
- outstandingItems: ListIngredientItem[]
- requestedMeals: RequestedMealItem[]
- purchasedItems: ListIngredientItem[]
The SDK maps these to a domain DTO (`CurrentShoppingListDTO`) and may attach refs (`ingredient`, `recipe`, `meal`, `list`) for convenience. UI code should:
- Prefer stable IDs (`ingredientId`, `mealId`, `recipeId`, `listId`) for actions and lookups
- Treat attached refs as optional view helpers (never required)
- Keep all normalization at the boundary (decoders); avoid casts and runtime type checks in app code

View file

@ -1,32 +1,18 @@
import { api } from '@/api/client' import { api } from '@/api/client'
import type { components } from '@/api/types' import type { components } from '@/api/types'
import { import { toDate, decodeMeal, decodeRecipe, decodeIngredients, decodeShoppingList, decodeShoppingListItems, decodeIngredient, decodeLookup } from '@/domain/decoders'
toDate,
decodeMeal,
decodeRecipe,
decodeIngredients,
decodeShoppingList,
decodeShoppingListItems,
decodeListIngredientItems,
decodeRequestedMealItems,
decodeIngredient,
decodeLookup,
} from '@/domain/decoders'
import type { import type {
Recipe, Recipe,
Meal, Meal,
Ingredient, Ingredient,
ShoppingList, ShoppingList,
ShoppingListItemWithRefs, ShoppingListItemWithRefs,
ListIngredientItemWithRefs,
RequestedMealItemWithRefs,
CurrentShoppingListDTO, CurrentShoppingListDTO,
PurchasedShoppingListDTO, PurchasedShoppingListDTO,
ShoppingLookups, ShoppingLookups,
} from '@/domain/types' } from '@/domain/types'
import { fromOpenApiPage, type Page } from '@/domain/pagination' import { fromOpenApiPage, type Page } from '@/domain/pagination'
import type { PurchaseRequest } from '@/domain/commands'
function httpError(response: Response, error: unknown): Error { function httpError(response: Response, error: unknown): Error {
if (error instanceof Error) return error if (error instanceof Error) return error
if (typeof error === 'string') return new Error(error) if (typeof error === 'string') return new Error(error)
@ -38,32 +24,34 @@ function httpError(response: Response, error: unknown): Error {
// Shopping list mapped view types now come from domain/types // Shopping list mapped view types now come from domain/types
function attachItemRefs( function attachItemRefs(
items: Array<ShoppingListItemWithRefs | ListIngredientItemWithRefs | RequestedMealItemWithRefs> | null | undefined, items: Array<ShoppingListItemWithRefs> | null | undefined,
lookups: ShoppingLookups lookups: ShoppingLookups
): void { ): void {
if (!Array.isArray(items)) return if (!Array.isArray(items)) return
for (const item of items) { for (const item of items) {
// ingredient ref const ingredientId = item.ingredientId ?? undefined
if ('ingredientId' in item && item.ingredientId !== undefined && lookups.ingredientsLookup) { const mealId = item.mealId ?? undefined
const v = lookups.ingredientsLookup[String(item.ingredientId)] const recipeId = item.recipeId ?? undefined
const listId = item.listId ?? undefined
const created = item.createdDate
if (ingredientId !== undefined && lookups.ingredientsLookup) {
const v = lookups.ingredientsLookup[String(ingredientId)]
if (v !== undefined) item.ingredient = v if (v !== undefined) item.ingredient = v
} }
// meal ref (present on all item types) if (mealId !== undefined && lookups.mealsLookup) {
if ('mealId' in item && item.mealId !== undefined && lookups.mealsLookup) { const v = lookups.mealsLookup[String(mealId)]
const v = lookups.mealsLookup[String(item.mealId)]
if (v !== undefined) item.meal = v if (v !== undefined) item.meal = v
} }
// recipe ref if (recipeId !== undefined && lookups.recipesLookup) {
if ('recipeId' in item && item.recipeId !== undefined && lookups.recipesLookup) { const v = lookups.recipesLookup[String(recipeId)]
const v = lookups.recipesLookup[String(item.recipeId)]
if (v !== undefined) item.recipe = v if (v !== undefined) item.recipe = v
} }
// list ref if (listId !== undefined && lookups.shoppingListLookup) {
if ('listId' in item && item.listId !== undefined && lookups.shoppingListLookup) { const v = lookups.shoppingListLookup[String(listId)]
const v = lookups.shoppingListLookup[String(item.listId)]
if (v !== undefined) item.list = v if (v !== undefined) item.list = v
} }
if ('createdDate' in item) item.createdDate = toDate(item.createdDate) if (created !== undefined) item.createdDate = toDate(created)
} }
} }
@ -127,9 +115,9 @@ export function mapCurrentShoppingList(dto: components['schemas']['CurrentShoppi
} }
const dtoOut: CurrentShoppingListDTO = { const dtoOut: CurrentShoppingListDTO = {
outstandingItems: decodeListIngredientItems(outstandingRaw ?? []), outstandingItems: decodeShoppingListItems(outstandingRaw ?? []),
requestedMeals: decodeRequestedMealItems(requestedRaw ?? []), requestedMeals: decodeShoppingListItems(requestedRaw ?? []),
purchasedItems: decodeListIngredientItems(purchasedRaw ?? []), purchasedItems: decodeShoppingListItems(purchasedRaw ?? []),
...(ingredientsLookup && { ingredientsLookup }), ...(ingredientsLookup && { ingredientsLookup }),
...(mealsLookup && { mealsLookup }), ...(mealsLookup && { mealsLookup }),
...(recipesLookup && { recipesLookup }), ...(recipesLookup && { recipesLookup }),
@ -311,26 +299,33 @@ export async function getCurrentShoppingList(): Promise<CurrentShoppingListDTO>
return mapped return mapped
} }
// PurchaseRequest comes from domain/commands type PurchaseExisting = { type: 'existing'; id: number; personId: number; ingredientId?: number | null }
type PurchaseRefs = { type: 'refs'; personId: number; ingredientId?: number | null; recipeId?: number | null; mealId?: number | null }
export type PurchaseRequest = PurchaseExisting | PurchaseRefs
export async function purchaseShoppingList( export async function purchaseShoppingList(
completedRequests: PurchaseRequest[] completedRequests: PurchaseRequest[]
): Promise<import('@/domain/types').ShoppingListWithRefs | null> { ): Promise<import('@/domain/types').ShoppingListWithRefs | null> {
if (!Array.isArray(completedRequests) || completedRequests.length === 0) return null if (!Array.isArray(completedRequests) || completedRequests.length === 0) return null
// Map incoming requests: if id provided and >= 0, use it; otherwise send identifiers for ingredient/recipe/meal // Map incoming requests: if id provided and >= 0, use it; otherwise send identifiers for ingredient/recipe/meal
const items: components['schemas']['IngredientPurchaseItemIn'][] = completedRequests.map((i) => ({ const items: components['schemas']['ShoppingListItem'][] = completedRequests.map((i) =>
i.type === 'existing'
? { id: i.id, personId: i.personId, ingredientId: i.ingredientId ?? null }
: {
id: -1,
personId: i.personId, personId: i.personId,
ingredientId: i.ingredientId ?? -1, ingredientId: i.ingredientId ?? null,
recipeId: i.type === 'refs' ? i.recipeId ?? null : null, recipeId: i.recipeId ?? null,
mealId: i.type === 'refs' ? i.mealId ?? null : null, mealId: i.mealId ?? null,
createdDate: null, }
})) )
if (items.length === 0) return null if (items.length === 0) return null
const body: components['schemas']['PurchaseListIn'] = { const body: components['schemas']['ShoppingList'] = {
// Default to a valid StoreNameOut per updated OpenAPI ("home" | "coles" | "woolworths") id: -1,
storeName: 'home', storeName: '',
purchasedById: -1,
items, items,
} }
const { data, error, response } = await api.POST('/api/v1/shopping', { const { data, error, response } = await api.POST('/api/v1/shopping', {
@ -356,6 +351,3 @@ export async function unrequestMeal(mealId: number | string): Promise<void> {
}) })
if (!response.ok) throw httpError(response, error) if (!response.ok) throw httpError(response, error)
} }
// Re-export domain command types for convenience at SDK surface
export type { PurchaseRequest } from '@/domain/commands'

View file

@ -340,25 +340,25 @@ export interface components {
/** CurrentShoppingList */ /** CurrentShoppingList */
CurrentShoppingList: { CurrentShoppingList: {
/** Outstandingitems */ /** Outstandingitems */
outstandingItems: components["schemas"]["ListIngredientItem"][]; outstandingItems: components["schemas"]["ShoppingListItem"][];
/** Requestedmeals */ /** Requestedmeals */
requestedMeals: components["schemas"]["RequestedMealItem"][]; requestedMeals: components["schemas"]["ShoppingListItem"][];
/** Purchaseditems */ /** Purchaseditems */
purchasedItems: components["schemas"]["ListIngredientItem"][]; purchasedItems?: components["schemas"]["ShoppingListItem"][];
/** Ingredientslookup */ /** Ingredientslookup */
ingredientsLookup: { ingredientsLookup?: {
[key: string]: components["schemas"]["Ingredient"]; [key: string]: components["schemas"]["Ingredient"];
}; };
/** Mealslookup */ /** Mealslookup */
mealsLookup: { mealsLookup?: {
[key: string]: components["schemas"]["Meal-Output"]; [key: string]: components["schemas"]["Meal-Output"];
}; };
/** Shoppinglistlookup */ /** Shoppinglistlookup */
shoppingListLookup: { shoppingListLookup?: {
[key: string]: components["schemas"]["ShoppingListOut"]; [key: string]: components["schemas"]["ShoppingList"];
}; };
/** Recipeslookup */ /** Recipeslookup */
recipesLookup: { recipesLookup?: {
[key: string]: components["schemas"]["Recipe-Output"]; [key: string]: components["schemas"]["Recipe-Output"];
}; };
}; };
@ -404,49 +404,6 @@ export interface components {
mealId?: number | null; mealId?: number | null;
product?: components["schemas"]["Product"] | null; product?: components["schemas"]["Product"] | null;
}; };
/** IngredientPurchaseItemIn */
IngredientPurchaseItemIn: {
/** Ingredientid */
ingredientId: number;
/** Personid */
personId: number;
/** Createddate */
createdDate?: string | null;
/** Mealid */
mealId?: number | null;
/** Recipeid */
recipeId?: number | null;
};
/** ListIngredientItem */
ListIngredientItem: {
/**
* Kind
* @default ingredient
* @constant
* @enum {string}
*/
kind: "ingredient";
/**
* Id
* @default -1
*/
id: number;
/** Ingredientid */
ingredientId: number;
/** Personid */
personId: number;
/**
* Createddate
* Format: date-time
*/
createdDate: string;
/** Listid */
listId?: number | null;
/** Mealid */
mealId?: number | null;
/** Recipeid */
recipeId?: number | null;
};
/** LoginBody */ /** LoginBody */
LoginBody: { LoginBody: {
/** Username */ /** Username */
@ -511,33 +468,6 @@ export interface components {
/** Mealid */ /** Mealid */
mealId: number; mealId: number;
}; };
/** MealOut */
MealOut: {
/**
* Id
* @default -1
*/
id: number;
/**
* Suggesteddate
* Format: date-time
*/
suggestedDate: string;
/** Consumeddate */
consumedDate?: string | null;
/** Chefs */
chefs: components["schemas"]["Person"][];
/** Cleanup */
cleanup: components["schemas"]["Person"][];
/** Consumers */
consumers: components["schemas"]["Person"][];
/** Recipes */
recipes: components["schemas"]["MealRecipe-Output"][];
/** Extraingredients */
extraIngredients: components["schemas"]["Ingredient"][];
/** Purchasedate */
purchaseDate?: string | null;
};
/** MealRecipe */ /** MealRecipe */
"MealRecipe-Input": { "MealRecipe-Input": {
/** Mealid */ /** Mealid */
@ -581,10 +511,10 @@ export interface components {
*/ */
total: number; total: number;
}; };
/** Page[RecipeOut] */ /** Page[Recipe] */
Page_RecipeOut_: { Page_Recipe_: {
/** Items */ /** Items */
items: components["schemas"]["RecipeOut"][]; items: components["schemas"]["Recipe-Output"][];
/** Nextcursor */ /** Nextcursor */
nextCursor?: string | null; nextCursor?: string | null;
/** Prevcursor */ /** Prevcursor */
@ -655,25 +585,19 @@ export interface components {
/** Tags */ /** Tags */
tags?: string[]; tags?: string[];
}; };
/** PurchaseListIn */
PurchaseListIn: {
storeName: components["schemas"]["StoreNameOut"];
/** Items */
items: components["schemas"]["IngredientPurchaseItemIn"][];
};
/** PurchasedShoppingList */ /** PurchasedShoppingList */
PurchasedShoppingList: { PurchasedShoppingList: {
list: components["schemas"]["ShoppingListOut"]; list: components["schemas"]["ShoppingList"];
/** Mealslookup */ /** Mealslookup */
mealsLookup: { mealsLookup?: {
[key: string]: components["schemas"]["Meal-Output"]; [key: string]: components["schemas"]["Meal-Output"];
}; };
/** Ingredientslookup */ /** Ingredientslookup */
ingredientsLookup: { ingredientsLookup?: {
[key: string]: components["schemas"]["Ingredient"]; [key: string]: components["schemas"]["Ingredient"];
}; };
/** Recipeslookup */ /** Recipeslookup */
recipesLookup: { recipesLookup?: {
[key: string]: components["schemas"]["Recipe-Output"]; [key: string]: components["schemas"]["Recipe-Output"];
}; };
}; };
@ -743,82 +667,54 @@ export interface components {
hiddenById?: number | null; hiddenById?: number | null;
hiddenBy?: components["schemas"]["Person"] | null; hiddenBy?: components["schemas"]["Person"] | null;
}; };
/** RecipeOut */ /** ShoppingList */
RecipeOut: { ShoppingList: {
/** /**
* Id * Id
* @default -1 * @default -1
*/ */
id: number; id: number;
/** Name */
name: string;
/** Link */
link: string;
/** Serves */
serves: number;
/** Imageurls */
imageUrls: string[];
/** Ingredients */
ingredients: components["schemas"]["Ingredient"][];
/** Basedonrecipe */
basedOnRecipe?: number | null;
/** /**
* Datecreated * Createddate
* Format: date-time * Format: date-time
*/ */
dateCreated: string; createdDate?: string;
/** Createdbyid */ /** @default */
createdById: number; storeName: components["schemas"]["StoreEnum"];
createdBy?: components["schemas"]["Person"] | null;
/** Datehidden */
dateHidden?: string | null;
/** Hiddenbyid */
hiddenById?: number | null;
hiddenBy?: components["schemas"]["Person"] | null;
};
/** RequestedMealItem */
RequestedMealItem: {
/** /**
* Kind * Purchasedbyid
* @default requestedMeal
* @constant
* @enum {string}
*/
kind: "requestedMeal";
/**
* Id
* @default -1 * @default -1
*/ */
id: number;
/** Personid */
personId: number;
/** Mealid */
mealId: number;
/**
* Createddate
* Format: date-time
*/
createdDate: string;
};
/** ShoppingListOut */
ShoppingListOut: {
/** Id */
id: number;
/**
* Createddate
* Format: date-time
*/
createdDate: string;
/**
* Storename
* @enum {string}
*/
storeName: "woolworths" | "coles" | "home";
/** Purchasedbyid */
purchasedById: number; purchasedById: number;
purchasedBy?: components["schemas"]["Person"] | null; purchasedBy?: components["schemas"]["Person"] | null;
/** Items */ /** Items */
items: components["schemas"]["ListIngredientItem"][]; items?: components["schemas"]["ShoppingListItem"][];
};
/** ShoppingListItem */
ShoppingListItem: {
/**
* Id
* @default -1
*/
id: number;
/** Listid */
listId?: number | null;
/**
* Personid
* @default -1
*/
personId: number;
/** Ingredientid */
ingredientId?: number | null;
/** Recipeid */
recipeId?: number | null;
/** Mealid */
mealId?: number | null;
/**
* Createddate
* Format: date-time
*/
createdDate?: string;
}; };
/** /**
* StoreEnum * StoreEnum
@ -834,11 +730,6 @@ export interface components {
/** Error Type */ /** Error Type */
type: string; type: string;
}; };
/**
* StoreNameOut
* @enum {string}
*/
StoreNameOut: "woolworths" | "coles" | "home";
}; };
responses: { responses: {
/** @description Bad Request */ /** @description Bad Request */
@ -931,7 +822,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["RecipeOut"]; "application/json": components["schemas"]["Recipe-Output"];
}; };
}; };
400: components["responses"]["Problem400"]; 400: components["responses"]["Problem400"];
@ -1016,7 +907,7 @@ export interface operations {
* "total": 1 * "total": 1
* } * }
*/ */
"application/json": components["schemas"]["Page_RecipeOut_"]; "application/json": components["schemas"]["Page_Recipe_"];
}; };
}; };
/** @description Validation Error */ /** @description Validation Error */
@ -1051,7 +942,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["RecipeOut"]; "application/json": components["schemas"]["Recipe-Output"];
}; };
}; };
400: components["responses"]["Problem400"]; 400: components["responses"]["Problem400"];
@ -1083,7 +974,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["RecipeOut"]; "application/json": components["schemas"]["Recipe-Output"];
}; };
}; };
404: components["responses"]["Problem404"]; 404: components["responses"]["Problem404"];
@ -1181,7 +1072,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["MealOut"]; "application/json": components["schemas"]["Meal-Output"];
}; };
}; };
404: components["responses"]["Problem404"]; 404: components["responses"]["Problem404"];
@ -1217,7 +1108,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["MealOut"]; "application/json": components["schemas"]["Meal-Output"];
}; };
}; };
400: components["responses"]["Problem400"]; 400: components["responses"]["Problem400"];
@ -1252,7 +1143,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["MealOut"]; "application/json": components["schemas"]["Meal-Output"];
}; };
}; };
404: components["responses"]["Problem404"]; 404: components["responses"]["Problem404"];
@ -1286,7 +1177,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["MealOut"]; "application/json": components["schemas"]["Meal-Output"];
}; };
}; };
400: components["responses"]["Problem400"]; 400: components["responses"]["Problem400"];
@ -1322,7 +1213,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["MealOut"]; "application/json": components["schemas"]["Meal-Output"];
}; };
}; };
400: components["responses"]["Problem400"]; 400: components["responses"]["Problem400"];
@ -1402,7 +1293,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["PurchaseListIn"]; "application/json": components["schemas"]["ShoppingList"];
}; };
}; };
responses: { responses: {
@ -1524,7 +1415,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["RequestedMealItem"]; "application/json": components["schemas"]["ShoppingListItem"];
}; };
}; };
404: components["responses"]["Problem404"]; 404: components["responses"]["Problem404"];

View file

@ -1,15 +1,15 @@
import * as sdk from '@/api/sdk' import * as sdk from '@/api/sdk'
import type { ListIngredientItemWithRefs, RequestedMealItemWithRefs, Product, Meal } from '@/domain/types' import type { ShoppingListItemWithRefs, Product, Meal } from '@/domain/types'
export type GroupByProduct = { type: 'product'; product: Product; shoppingListItems: ListIngredientItemWithRefs[] } export type GroupByProduct = { type: 'product'; product: Product; shoppingListItems: ShoppingListItemWithRefs[] }
export type GroupByName = { type: 'name'; name: string; shoppingListItems: ListIngredientItemWithRefs[] } export type GroupByName = { type: 'name'; name: string; shoppingListItems: ShoppingListItemWithRefs[] }
export type Group = GroupByProduct | GroupByName export type Group = GroupByProduct | GroupByName
export function groupsToItems(groups: Group[]): ListIngredientItemWithRefs[] { export function groupsToItems(groups: Group[]): ShoppingListItemWithRefs[] {
return groups.map((g) => g.shoppingListItems).flat() return groups.map((g) => g.shoppingListItems).flat()
} }
export function uniqueMeals(shoppingListItems: Array<ListIngredientItemWithRefs | RequestedMealItemWithRefs>): Meal[] { export function uniqueMeals(shoppingListItems: ShoppingListItemWithRefs[]): Meal[] {
const mealsWithDuplicates = shoppingListItems.map((item) => item.meal).filter((m): m is Meal => !!m) const mealsWithDuplicates = shoppingListItems.map((item) => item.meal).filter((m): m is Meal => !!m)
const mealsLookup: Record<string | number, Meal> = mealsWithDuplicates.reduce<Record<string | number, Meal>>( const mealsLookup: Record<string | number, Meal> = mealsWithDuplicates.reduce<Record<string | number, Meal>>(
(acc, meal) => { (acc, meal) => {
@ -21,7 +21,7 @@ export function uniqueMeals(shoppingListItems: Array<ListIngredientItemWithRefs
return Object.values(mealsLookup) return Object.values(mealsLookup)
} }
export function itemsToGroups(shoppingListItems: ListIngredientItemWithRefs[]): Group[] { export function itemsToGroups(shoppingListItems: ShoppingListItemWithRefs[]): Group[] {
const ingredients_by_product_id: Record<string | number, GroupByProduct> = {} const ingredients_by_product_id: Record<string | number, GroupByProduct> = {}
const ingredients_by_name: Record<string, GroupByName> = {} const ingredients_by_name: Record<string, GroupByName> = {}
for (const item of shoppingListItems) { for (const item of shoppingListItems) {
@ -52,8 +52,8 @@ export function itemsToGroups(shoppingListItems: ListIngredientItemWithRefs[]):
} }
export function useShopping() { export function useShopping() {
const groupsFrom = (items?: ListIngredientItemWithRefs[]): Group[] => itemsToGroups(items ?? []) const groupsFrom = (items?: ShoppingListItemWithRefs[]): Group[] => itemsToGroups(items ?? [])
const mealsFrom = (items?: Array<ListIngredientItemWithRefs | RequestedMealItemWithRefs>): Meal[] => uniqueMeals(items ?? []) const mealsFrom = (items?: ShoppingListItemWithRefs[]): Meal[] => uniqueMeals(items ?? [])
return { return {
getCurrentShoppingList: sdk.getCurrentShoppingList, getCurrentShoppingList: sdk.getCurrentShoppingList,
getShoppingList: sdk.getShoppingList, getShoppingList: sdk.getShoppingList,
@ -68,14 +68,14 @@ export function useShopping() {
async purchaseFromGroups(groups: Group[]) { async purchaseFromGroups(groups: Group[]) {
const items = groupsToItems(groups).map((i): sdk.PurchaseRequest => { const items = groupsToItems(groups).map((i): sdk.PurchaseRequest => {
if (typeof i.id === 'number' && i.id >= 0) { if (typeof i.id === 'number' && i.id >= 0) {
return { type: 'existing', id: i.id, personId: i.personId, ingredientId: i.ingredientId ?? null } return { type: 'existing', id: i.id, personId: i.personId, ingredientId: i.ingredient?.id ?? null }
} }
return { return {
type: 'refs', type: 'refs',
personId: i.personId, personId: i.personId,
ingredientId: i.ingredientId ?? null, ingredientId: i.ingredient?.id ?? null,
recipeId: i.recipeId ?? null, recipeId: i.recipe?.id ?? null,
mealId: i.mealId ?? null, mealId: i.meal?.id ?? null,
} }
}) })
if (!items?.length) return null if (!items?.length) return null

View file

@ -1,5 +0,0 @@
// Domain command types: stable UI intents, translated to OpenAPI at the SDK boundary
export type PurchaseExisting = { type: 'existing'; id: number; personId: number; ingredientId?: number | null }
export type PurchaseRefs = { type: 'refs'; personId: number; ingredientId?: number | null; recipeId?: number | null; mealId?: number | null }
export type PurchaseRequest = PurchaseExisting | PurchaseRefs

View file

@ -1,4 +1,4 @@
import type { Recipe, Meal, MealRecipe, Ingredient as DomainIngredient, ShoppingList, ShoppingListItem, ShoppingListItemWithRefs, MealInput, ListIngredientItem, RequestedMealItem, ListIngredientItemWithRefs, RequestedMealItemWithRefs } from './types' import type { RecipeOut, Recipe, MealOut, Meal, MealRecipe, Ingredient as DomainIngredient, ShoppingList, ShoppingListItem, ShoppingListItemWithRefs, MealInput } from './types'
import type { components } from '@/api/types' import type { components } from '@/api/types'
export function toDate(value: string | Date | null | undefined): Date | null { export function toDate(value: string | Date | null | undefined): Date | null {
@ -23,25 +23,16 @@ export function decodeLookup<TIn, TOut>(
return out return out
} }
export function decodeRecipe( export function decodeRecipe(r: RecipeOut | null | undefined): Recipe {
r: components['schemas']['RecipeOut'] | components['schemas']['Recipe-Output'] | null | undefined
): Recipe {
if (!r) throw new Error('Invalid recipe payload') if (!r) throw new Error('Invalid recipe payload')
// Normalize arrays that may be optional in legacy Recipe-Output
const imageUrls = r.imageUrls ?? []
const ingredients = r.ingredients ?? []
return { return {
...r, ...r,
imageUrls,
ingredients,
dateCreated: toDate(r.dateCreated), dateCreated: toDate(r.dateCreated),
dateHidden: toDate(r.dateHidden), dateHidden: toDate(r.dateHidden),
} }
} }
export function decodeMeal( export function decodeMeal(m: MealOut | null | undefined): Meal {
m: components['schemas']['MealOut'] | components['schemas']['Meal-Output'] | null | undefined
): Meal {
if (!m) throw new Error('Invalid meal payload') if (!m) throw new Error('Invalid meal payload')
const recipes = Array.isArray(m.recipes) const recipes = Array.isArray(m.recipes)
? m.recipes.map((mr) => decodeMealRecipe(mr)) ? m.recipes.map((mr) => decodeMealRecipe(mr))
@ -79,7 +70,7 @@ export function decodeIngredients(list: components['schemas']['Ingredient'][] |
return list.map((i) => decodeIngredient(i)) return list.map((i) => decodeIngredient(i))
} }
function decodeShoppingListItem(i: components['schemas']['ListIngredientItem'] | null | undefined): ShoppingListItem { function decodeShoppingListItem(i: components['schemas']['ShoppingListItem'] | null | undefined): ShoppingListItem {
if (!i) throw new Error('Invalid shopping list item payload') if (!i) throw new Error('Invalid shopping list item payload')
return { return {
...i, ...i,
@ -87,13 +78,13 @@ function decodeShoppingListItem(i: components['schemas']['ListIngredientItem'] |
} }
} }
export function decodeShoppingListItems(list: components['schemas']['ListIngredientItem'][] | null | undefined): ShoppingListItemWithRefs[] { export function decodeShoppingListItems(list: components['schemas']['ShoppingListItem'][] | null | undefined): ShoppingListItemWithRefs[] {
if (!Array.isArray(list)) return [] if (!Array.isArray(list)) return []
// Build a new array with item clones to allow optional refs to be attached later // Build a new array with item clones to allow optional refs to be attached later
return list.map((raw) => ({ ...decodeShoppingListItem(raw) })) return list.map((raw) => ({ ...decodeShoppingListItem(raw) }))
} }
export function decodeShoppingList(v: components['schemas']['ShoppingListOut'] | null | undefined): ShoppingList { export function decodeShoppingList(v: components['schemas']['ShoppingList'] | null | undefined): ShoppingList {
if (!v) throw new Error('Invalid shopping list payload') if (!v) throw new Error('Invalid shopping list payload')
const { items: rawItems, ...rest } = v const { items: rawItems, ...rest } = v
const items = Array.isArray(rawItems) ? decodeShoppingListItems(rawItems) : undefined const items = Array.isArray(rawItems) ? decodeShoppingListItems(rawItems) : undefined
@ -104,33 +95,6 @@ export function decodeShoppingList(v: components['schemas']['ShoppingListOut'] |
} }
} }
// New item decoders for tightened CurrentShoppingList
function decodeListIngredientItem(i: components['schemas']['ListIngredientItem'] | null | undefined): ListIngredientItem {
if (!i) throw new Error('Invalid list ingredient item payload')
return {
...i,
createdDate: toDate(i.createdDate),
}
}
export function decodeListIngredientItems(list: components['schemas']['ListIngredientItem'][] | null | undefined): ListIngredientItemWithRefs[] {
if (!Array.isArray(list)) return []
return list.map((raw) => ({ ...decodeListIngredientItem(raw) }))
}
function decodeRequestedMealItem(i: components['schemas']['RequestedMealItem'] | null | undefined): RequestedMealItem {
if (!i) throw new Error('Invalid requested meal item payload')
return {
...i,
createdDate: toDate(i.createdDate),
}
}
export function decodeRequestedMealItems(list: components['schemas']['RequestedMealItem'][] | null | undefined): RequestedMealItemWithRefs[] {
if (!Array.isArray(list)) return []
return list.map((raw) => ({ ...decodeRequestedMealItem(raw) }))
}
// Helper to convert domain Meal to MealInput, keeping Date→string conversion in boundary // Helper to convert domain Meal to MealInput, keeping Date→string conversion in boundary
export function toMealInput(meal: Meal): MealInput { export function toMealInput(meal: Meal): MealInput {
return { return {

View file

@ -3,10 +3,6 @@ import type { components } from '@/api/types'
// Utility mapped types // Utility mapped types
export type Replace<T, M> = Omit<T, keyof M> & M export type Replace<T, M> = Omit<T, keyof M> & M
export type WithDates<T, K extends keyof T> = Replace<T, { [P in K]: Date | null }> export type WithDates<T, K extends keyof T> = Replace<T, { [P in K]: Date | null }>
// Convert selected array keys to their non-nullable array counterparts
export type NonNullableArrays<T, K extends keyof T> = Replace<T, { [P in K]: NonNullable<T[P]> extends Array<infer U> ? U[] : T[P] }>
// Make selected keys required (non-optional) on a type
export type RequiredKeys<T, K extends keyof T> = Replace<T, { [P in K]-?: NonNullable<T[P]> }>
// Common helpers (intentionally minimal to avoid unused exports) // Common helpers (intentionally minimal to avoid unused exports)
export type Lookup<T> = Record<string, T> export type Lookup<T> = Record<string, T>
@ -14,8 +10,8 @@ export type Lookup<T> = Record<string, T>
export type WithRefs<T, Refs extends object> = T & { [K in keyof Refs]?: Refs[K] | undefined } export type WithRefs<T, Refs extends object> = T & { [K in keyof Refs]?: Refs[K] | undefined }
// Domain type aliases // Domain type aliases
export type RecipeOut = components['schemas']['RecipeOut'] export type RecipeOut = components['schemas']['Recipe-Output']
export type MealOut = components['schemas']['MealOut'] export type MealOut = components['schemas']['Meal-Output']
export type Ingredient = components['schemas']['Ingredient'] export type Ingredient = components['schemas']['Ingredient']
export type Product = components['schemas']['Product'] export type Product = components['schemas']['Product']
export type Person = components['schemas']['Person'] export type Person = components['schemas']['Person']
@ -31,26 +27,24 @@ export type MealRecipe = Replace<MealRecipeOut, { recipe: Recipe | null }>
// Meal with decoded dates and nested MealRecipe with decoded recipe dates // Meal with decoded dates and nested MealRecipe with decoded recipe dates
// Arrays (chefs, consumers, cleanup, recipes, extraIngredients) are non-nullable per OpenAPI spec // Arrays (chefs, consumers, cleanup, recipes, extraIngredients) are non-nullable per OpenAPI spec
type MealBase = Replace<WithDates<MealOut, 'suggestedDate' | 'consumedDate' | 'purchaseDate'>, { recipes: MealRecipe[] }> export type Meal = Replace<
export type Meal = RequiredKeys< WithDates<MealOut, 'suggestedDate' | 'consumedDate' | 'purchaseDate'>,
NonNullableArrays<MealBase, 'chefs' | 'consumers' | 'cleanup' | 'extraIngredients'>, {
'recipes' | 'chefs' | 'consumers' | 'cleanup' | 'extraIngredients' recipes: MealRecipe[]
chefs: Person[]
consumers: Person[]
cleanup: Person[]
extraIngredients: Ingredient[]
}
> >
// Shopping domain shapes with dates normalized // Shopping domain shapes with dates normalized
// Items inside purchased lists and current lists share the ListIngredientItem shape export type ShoppingListItem = WithDates<components['schemas']['ShoppingListItem'], 'createdDate'>
export type ShoppingListItem = WithDates<components['schemas']['ListIngredientItem'], 'createdDate'> type ShoppingListBase = WithDates<components['schemas']['ShoppingList'], 'createdDate'>
type ShoppingListBase = WithDates<components['schemas']['ShoppingListOut'], 'createdDate'>
export type ShoppingList = Replace<ShoppingListBase, { items?: ShoppingListItem[] | undefined }> export type ShoppingList = Replace<ShoppingListBase, { items?: ShoppingListItem[] | undefined }>
// Current shopping list item types (tightened OpenAPI) // Refs attached to shopping list items
export type ListIngredientItem = WithDates<components['schemas']['ListIngredientItem'], 'createdDate'>
export type RequestedMealItem = WithDates<components['schemas']['RequestedMealItem'], 'createdDate'>
// Refs attached to items
export type ShoppingListItemWithRefs = WithRefs<ShoppingListItem, { ingredient: Ingredient; recipe: Recipe; meal: Meal; list: ShoppingList }> export type ShoppingListItemWithRefs = WithRefs<ShoppingListItem, { ingredient: Ingredient; recipe: Recipe; meal: Meal; list: ShoppingList }>
export type ListIngredientItemWithRefs = WithRefs<ListIngredientItem, { ingredient: Ingredient; recipe: Recipe; meal: Meal; list: ShoppingList }>
export type RequestedMealItemWithRefs = WithRefs<RequestedMealItem, { meal: Meal }>
export type ShoppingListWithRefs = Replace<ShoppingList, { items?: ShoppingListItemWithRefs[] }> export type ShoppingListWithRefs = Replace<ShoppingList, { items?: ShoppingListItemWithRefs[] }>
@ -64,9 +58,9 @@ export type ShoppingLookups = {
// DTO shapes returned by SDK for shopping pages // DTO shapes returned by SDK for shopping pages
export type CurrentShoppingListDTO = { export type CurrentShoppingListDTO = {
outstandingItems: ListIngredientItemWithRefs[] outstandingItems: ShoppingListItemWithRefs[]
requestedMeals: RequestedMealItemWithRefs[] requestedMeals: ShoppingListItemWithRefs[]
purchasedItems: ListIngredientItemWithRefs[] purchasedItems: ShoppingListItemWithRefs[]
} & ShoppingLookups } & ShoppingLookups
export type PurchasedShoppingListDTO = ShoppingLookups & { export type PurchasedShoppingListDTO = ShoppingLookups & {

View file

@ -1,48 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mapCurrentShoppingList, mapPurchasedShoppingList } from '@/api/sdk'
// These tests lock boundary behavior for partial/missing lookups and date normalization
describe('shopping mappers boundary', () => {
it('handles missing lookups gracefully (no refs attached)', () => {
const dto = {
outstandingItems: [
{ ingredientId: 10, mealId: 1, recipeId: 5, listId: 7, createdDate: '2025-01-01T00:00:00Z' },
],
requestedMeals: [
{ mealId: 2, createdDate: '2025-01-02T00:00:00Z' },
],
purchasedItems: [],
}
const mapped = mapCurrentShoppingList(dto as any)
expect(mapped.outstandingItems[0].ingredient).toBeUndefined()
expect(mapped.outstandingItems[0].meal).toBeUndefined()
expect(mapped.outstandingItems[0].recipe).toBeUndefined()
expect(mapped.outstandingItems[0].list).toBeUndefined()
expect(mapped.requestedMeals[0].meal).toBeUndefined()
// Dates normalized
expect(mapped.outstandingItems[0].createdDate).toBeInstanceOf(Date)
expect(mapped.requestedMeals[0].createdDate).toBeInstanceOf(Date)
})
it('normalizes dates on purchased list and items even with partial lookups', () => {
const dto = {
ingredientsLookup: { 10: { id: 10, name: 'Milk' } },
list: {
id: 11,
createdDate: '2025-03-03T00:00:00Z',
items: [
{ ingredientId: 10, listId: 11, createdDate: '2025-03-03T00:00:00Z' },
{ ingredientId: 99, listId: 11, createdDate: '2025-03-03T00:00:00Z' },
],
},
}
const mapped = mapPurchasedShoppingList(dto as any)
expect(mapped.list.createdDate).toBeInstanceOf(Date)
expect(mapped.list.items[0].createdDate).toBeInstanceOf(Date)
expect(mapped.list.items[1].createdDate).toBeInstanceOf(Date)
// First item gets ingredient ref, second does not
expect(mapped.list.items[0].ingredient?.name).toBe('Milk')
expect(mapped.list.items[1].ingredient).toBeUndefined()
})
})