Skip to content

Commit 8b49d6a

Browse files
committed
align recipe/grocery list endpoints and add client implementation
1 parent 0895ea7 commit 8b49d6a

36 files changed

Lines changed: 1068 additions & 761 deletions

client/src/App.tsx

Lines changed: 169 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -8,38 +8,76 @@ import { FeatureCards } from './components/FeatureCards'
88
import { AuthCard, type AuthPayload, type AuthUser } from './components/AuthCard'
99
import { GroceryListView } from './components/GroceryListView'
1010
import { RecipeListView } from './components/RecipeListView'
11-
import type { GroceryList, ApiRecipe } from './types'
11+
import type {
12+
GroceryList, ApiRecipe, ApiRecipeSummary, RecipeSummary, Ingredient,
13+
ApiGroceryList, ApiGroceryListSummary, GroceryListSummary, GroceryItemDetail,
14+
} from './types'
1215

1316
type View = 'home' | 'grocery-lists' | 'recipes'
14-
type RecipesStatus = 'loading' | 'ready' | 'error'
17+
type LoadStatus = 'loading' | 'ready' | 'error'
1518

16-
function apiRecipeToList(recipe: ApiRecipe): GroceryList {
19+
function apiSummaryToRecipe(summary: ApiRecipeSummary): RecipeSummary {
20+
return { id: summary.recipeId, dish: summary.name, createdAt: summary.createdAt }
21+
}
22+
23+
// The API uses the DB's numeric quantity (null = unspecified); the client keeps a display
24+
// string, so convert at the API boundary.
25+
function formatQuantity(quantity: number | null): string {
26+
return quantity === null ? 'N/A' : String(quantity)
27+
}
28+
29+
function parseQuantity(quantity: string): number | null {
30+
const trimmed = quantity?.trim()
31+
if (!trimmed || trimmed === 'N/A') return null
32+
const n = Number(trimmed)
33+
return Number.isFinite(n) ? n : null
34+
}
35+
36+
function apiItemsToIngredients(recipe: ApiRecipe): Ingredient[] {
37+
return recipe.items.map(item => ({
38+
name: item.name,
39+
quantity: formatQuantity(item.quantity),
40+
unit: item.unit,
41+
category: item.category,
42+
}))
43+
}
44+
45+
function apiSummaryToGroceryList(summary: ApiGroceryListSummary): GroceryListSummary {
1746
return {
18-
id: recipe.recipeId,
19-
dish: recipe.name,
20-
createdAt: recipe.createdAt,
21-
ingredients: recipe.items.map(item => ({
22-
name: item.name,
23-
quantity: item.quantity,
24-
unit: item.unit,
25-
category: item.category,
26-
})),
47+
id: summary.groceryListId,
48+
dish: summary.name,
49+
createdAt: summary.createdAt,
50+
itemCount: summary.itemCount,
51+
purchasedCount: summary.purchasedCount,
2752
}
2853
}
2954

30-
function getInitialDark(): boolean {
31-
const stored = localStorage.getItem('bytebite-dark')
32-
if (stored !== null) return stored === 'true'
33-
return window.matchMedia('(prefers-color-scheme: dark)').matches
55+
// POST/PUT return the full detail; derive the summary counts the list view needs from it.
56+
function detailToGrocerySummary(detail: ApiGroceryList): GroceryListSummary {
57+
return {
58+
id: detail.groceryListId,
59+
dish: detail.name,
60+
createdAt: detail.createdAt,
61+
itemCount: detail.items.length,
62+
purchasedCount: detail.items.filter(item => item.purchased).length,
63+
}
3464
}
3565

36-
function loadLists(userId: string): GroceryList[] {
37-
const stored = localStorage.getItem(`bytebite-lists-${userId}`)
38-
return stored ? JSON.parse(stored) as GroceryList[] : []
66+
function apiItemsToGroceryDetail(list: ApiGroceryList): GroceryItemDetail[] {
67+
return list.items.map(item => ({
68+
itemId: item.itemId,
69+
name: item.name,
70+
quantity: formatQuantity(item.quantity),
71+
unit: item.unit,
72+
category: item.category,
73+
purchased: item.purchased,
74+
}))
3975
}
4076

41-
function saveLists(userId: string, lists: GroceryList[]) {
42-
localStorage.setItem(`bytebite-lists-${userId}`, JSON.stringify(lists))
77+
function getInitialDark(): boolean {
78+
const stored = localStorage.getItem('bytebite-dark')
79+
if (stored !== null) return stored === 'true'
80+
return window.matchMedia('(prefers-color-scheme: dark)').matches
4381
}
4482

4583
function App() {
@@ -51,26 +89,55 @@ function App() {
5189
const stored = localStorage.getItem('bytebite-user')
5290
return stored ? JSON.parse(stored) as AuthUser : null
5391
})
54-
const [savedLists, setSavedLists] = useState<GroceryList[]>(() =>
55-
user ? loadLists(user.userId) : []
56-
)
57-
const [recipes, setRecipes] = useState<GroceryList[]>([])
58-
const [recipesStatus, setRecipesStatus] = useState<RecipesStatus>('loading')
92+
const [recipes, setRecipes] = useState<RecipeSummary[]>([])
93+
const [recipesStatus, setRecipesStatus] = useState<LoadStatus>('loading')
94+
const [groceryLists, setGroceryLists] = useState<GroceryListSummary[]>([])
95+
const [groceryStatus, setGroceryStatus] = useState<LoadStatus>('loading')
5996

6097
const loadRecipes = useCallback((authToken: string) => {
6198
setRecipesStatus('loading')
6299
fetch('/api/recipes', { headers: { Authorization: `Bearer ${authToken}` } })
63100
.then(response => {
64101
if (!response.ok) throw new Error('Failed to load recipes')
65-
return response.json() as Promise<ApiRecipe[]>
102+
return response.json() as Promise<ApiRecipeSummary[]>
66103
})
67104
.then(data => {
68-
setRecipes(data.map(apiRecipeToList))
105+
setRecipes(data.map(apiSummaryToRecipe))
69106
setRecipesStatus('ready')
70107
})
71108
.catch(() => setRecipesStatus('error'))
72109
}, [])
73110

111+
const loadGroceryLists = useCallback((authToken: string) => {
112+
setGroceryStatus('loading')
113+
fetch('/api/grocery-list', { headers: { Authorization: `Bearer ${authToken}` } })
114+
.then(response => {
115+
if (!response.ok) throw new Error('Failed to load grocery lists')
116+
return response.json() as Promise<ApiGroceryListSummary[]>
117+
})
118+
.then(data => {
119+
setGroceryLists(data.map(apiSummaryToGroceryList))
120+
setGroceryStatus('ready')
121+
})
122+
.catch(() => setGroceryStatus('error'))
123+
}, [])
124+
125+
const fetchRecipeItems = useCallback(async (recipeId: string): Promise<Ingredient[]> => {
126+
const response = await fetch(`/api/recipes/${recipeId}`, {
127+
headers: { Authorization: `Bearer ${token}` },
128+
})
129+
if (!response.ok) throw new Error('Failed to load recipe items')
130+
return apiItemsToIngredients(await response.json() as ApiRecipe)
131+
}, [token])
132+
133+
const fetchGroceryListItems = useCallback(async (listId: string): Promise<GroceryItemDetail[]> => {
134+
const response = await fetch(`/api/grocery-list/${listId}`, {
135+
headers: { Authorization: `Bearer ${token}` },
136+
})
137+
if (!response.ok) throw new Error('Failed to load grocery list items')
138+
return apiItemsToGroceryDetail(await response.json() as ApiGroceryList)
139+
}, [token])
140+
74141
useEffect(() => {
75142
document.documentElement.classList.toggle('dark', darkMode)
76143
localStorage.setItem('bytebite-dark', String(darkMode))
@@ -88,18 +155,18 @@ function App() {
88155
.then(payload => {
89156
setToken(payload.token)
90157
setUser(payload.user)
91-
setSavedLists(loadLists(payload.user.userId))
92158
localStorage.setItem('bytebite-token', payload.token)
93159
localStorage.setItem('bytebite-user', JSON.stringify(payload.user))
94160
loadRecipes(payload.token)
161+
loadGroceryLists(payload.token)
95162
})
96163
.catch(() => {
97164
localStorage.removeItem('bytebite-token')
98165
localStorage.removeItem('bytebite-user')
99166
setToken('')
100167
setUser(null)
101168
})
102-
}, [loadRecipes])
169+
}, [loadRecipes, loadGroceryLists])
103170

104171
const toggleDark = () => setDarkMode(d => !d)
105172
const openSidebar = () => setSidebarOpen(true)
@@ -113,42 +180,34 @@ function App() {
113180
const handleAuthenticated = (payload: AuthPayload) => {
114181
setToken(payload.token)
115182
setUser(payload.user)
116-
setSavedLists(loadLists(payload.user.userId))
117183
localStorage.setItem('bytebite-token', payload.token)
118184
localStorage.setItem('bytebite-user', JSON.stringify(payload.user))
119185
loadRecipes(payload.token)
186+
loadGroceryLists(payload.token)
120187
}
121188

122189
const handleLogout = () => {
123190
localStorage.removeItem('bytebite-token')
124191
localStorage.removeItem('bytebite-user')
125192
setToken('')
126193
setUser(null)
127-
setSavedLists([])
128194
setRecipes([])
195+
setGroceryLists([])
129196
setView('home')
130197
}
131198

199+
// A generated dish is persisted as both a recipe and a grocery list; each view reads its own resource.
132200
const handleListGenerated = (list: GroceryList) => {
133-
if (!user) return
134-
// Grocery lists stay client-side for now; recipes are persisted server-side.
135-
setSavedLists(prev => {
136-
const next = [list, ...prev]
137-
saveLists(user.userId, next)
138-
return next
139-
})
201+
if (!token) return
140202

141203
fetch('/api/recipes', {
142204
method: 'POST',
143-
headers: {
144-
'Content-Type': 'application/json',
145-
Authorization: `Bearer ${token}`,
146-
},
205+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
147206
body: JSON.stringify({
148207
name: list.dish,
149208
items: list.ingredients.map(item => ({
150209
name: item.name,
151-
quantity: item.quantity,
210+
quantity: parseQuantity(item.quantity),
152211
unit: item.unit,
153212
category: item.category,
154213
})),
@@ -158,9 +217,32 @@ function App() {
158217
if (!response.ok) throw new Error('Failed to save recipe')
159218
return response.json() as Promise<ApiRecipe>
160219
})
161-
.then(saved => setRecipes(prev => [apiRecipeToList(saved), ...prev]))
220+
.then(saved => setRecipes(prev => [apiSummaryToRecipe(saved), ...prev]))
162221
.catch(() => {
163-
// Recipe persistence failed; the grocery list is still saved locally.
222+
// Recipe persistence failed; the grocery list may still be saved.
223+
})
224+
225+
fetch('/api/grocery-list', {
226+
method: 'POST',
227+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
228+
body: JSON.stringify({
229+
name: list.dish,
230+
items: list.ingredients.map(item => ({
231+
name: item.name,
232+
quantity: parseQuantity(item.quantity),
233+
unit: item.unit,
234+
category: item.category,
235+
purchased: false,
236+
})),
237+
}),
238+
})
239+
.then(response => {
240+
if (!response.ok) throw new Error('Failed to save grocery list')
241+
return response.json() as Promise<ApiGroceryList>
242+
})
243+
.then(saved => setGroceryLists(prev => [detailToGrocerySummary(saved), ...prev]))
244+
.catch(() => {
245+
// Grocery-list persistence failed; the recipe may still be saved.
164246
})
165247
}
166248

@@ -177,31 +259,43 @@ function App() {
177259
.catch(() => setRecipes(previous))
178260
}
179261

180-
const handleToggleItem = (listId: string, itemIndex: number) => {
181-
if (!user) return
182-
setSavedLists(prev => {
183-
const next = prev.map(list =>
262+
// Persists a single item's picked-up state via PATCH and keeps the summary counts in sync.
263+
// Returns false if the server rejected the change so the view can revert its optimistic update.
264+
const handleToggleGroceryItem = useCallback(
265+
async (listId: string, itemId: string, purchased: boolean): Promise<boolean> => {
266+
const adjust = (delta: number) => setGroceryLists(prev => prev.map(list =>
184267
list.id === listId
185-
? {
186-
...list,
187-
ingredients: list.ingredients.map((item, i) =>
188-
i === itemIndex ? { ...item, checked: !item.checked } : item
189-
),
190-
}
268+
? { ...list, purchasedCount: Math.max(0, Math.min(list.itemCount, list.purchasedCount + delta)) }
191269
: list
192-
)
193-
saveLists(user.userId, next)
194-
return next
195-
})
196-
}
270+
))
271+
adjust(purchased ? 1 : -1)
272+
try {
273+
const response = await fetch(`/api/grocery-list/${listId}/items/${itemId}`, {
274+
method: 'PATCH',
275+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
276+
body: JSON.stringify({ purchased }),
277+
})
278+
if (!response.ok) throw new Error('Failed to update item')
279+
return true
280+
} catch {
281+
adjust(purchased ? -1 : 1)
282+
return false
283+
}
284+
},
285+
[token]
286+
)
197287

198288
const handleDeleteList = (listId: string) => {
199-
if (!user) return
200-
setSavedLists(prev => {
201-
const next = prev.filter(list => list.id !== listId)
202-
saveLists(user.userId, next)
203-
return next
289+
const previous = groceryLists
290+
setGroceryLists(prev => prev.filter(list => list.id !== listId))
291+
fetch(`/api/grocery-list/${listId}`, {
292+
method: 'DELETE',
293+
headers: { Authorization: `Bearer ${token}` },
204294
})
295+
.then(response => {
296+
if (!response.ok && response.status !== 404) throw new Error('Failed to delete grocery list')
297+
})
298+
.catch(() => setGroceryLists(previous))
205299
}
206300

207301
if (!token || !user) {
@@ -260,9 +354,12 @@ function App() {
260354
transition={{ duration: 0.15 }}
261355
>
262356
<GroceryListView
263-
lists={savedLists}
264-
onToggleItem={handleToggleItem}
357+
lists={groceryLists}
358+
status={groceryStatus}
359+
onRetry={() => loadGroceryLists(token)}
360+
onToggleItem={handleToggleGroceryItem}
265361
onDeleteList={handleDeleteList}
362+
fetchItems={fetchGroceryListItems}
266363
/>
267364
</motion.div>
268365
) : view === 'recipes' ? (
@@ -274,10 +371,11 @@ function App() {
274371
transition={{ duration: 0.15 }}
275372
>
276373
<RecipeListView
277-
lists={recipes}
374+
recipes={recipes}
278375
status={recipesStatus}
279376
onRetry={() => loadRecipes(token)}
280-
onDeleteList={handleDeleteRecipe}
377+
onDeleteRecipe={handleDeleteRecipe}
378+
fetchItems={fetchRecipeItems}
281379
/>
282380
</motion.div>
283381
) : (
@@ -303,4 +401,4 @@ function App() {
303401
)
304402
}
305403

306-
export default App
404+
export default App

0 commit comments

Comments
 (0)