@@ -8,38 +8,76 @@ import { FeatureCards } from './components/FeatureCards'
88import { AuthCard , type AuthPayload , type AuthUser } from './components/AuthCard'
99import { GroceryListView } from './components/GroceryListView'
1010import { 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
1316type 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
4583function 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