valentyn-vb / valentyn-vb/FoodNote
Move dashboard data fetching to Server Components and Server Actions
@riedel28 is already working on this.
Since Jul 28, 2026.
- Dominant language
- TypeScript
- Stars
- 1
- Forks
- 1
- PR merge metrics
- No merged PRs in 30d
Description
Goal/Context
The frontend fetches all app data client-side through hand-rolled providers. MealsProvider and WeightProvider each run a useEffect with a cancelled flag, keep their own status: 'loading' | 'error' | 'ready', expose a retry() driven by a reloadKey, and fan mutations out through callbacks (onWeightSaved, onWeightUpdated, onWeightRemoved, refetchDashboard). That callback surface is cache invalidation written by hand: every mutation has to know which other read models it invalidates.
It already produces bugs. In #75, onWeightUpdated patches the provider's 60-day array in place, so re-dating an entry outside that window leaves a phantom row in the drawer and the client-computed trend while the server-derived goal block disagrees — until F5. canDelete has the same root cause: it counts the fetched window rather than the journal.
Next's recommended shape removes the class rather than the instance — the server re-renders from source after every mutation, so there is no client cache to drift. This ticket does the dashboard only; profile and onboarding stay on the current pattern until this is proven.
Blocker, already cleared. No server-side fetch can currently authenticate — the session PR (#88, #92, #108, sequenced in #110) moves the tokens into two Next-owned httpOnly cookies and puts serverFetch in lib/server/, so by the time this ticket starts, a Server Component can authenticate. This ticket does not touch auth.
Scope
This is PR 5 of the eight in #110 — the pilot slice, and the one that deletes both providers. Because it deletes them, everything else that consumes them comes with it, whether or not it is "the dashboard".
- Reads move to
lib/server/, on top of theserverFetchthat PR 1 already shipped — no data module of this ticket's own, and no cookie handling here. Responses keep going through theshared/Zod schemas, and per #89 that parsing lives only in the server data layer. dashboard/page.tsxbecomesasync— fetches dashboard, meals and weights withPromise.all, passes plain data as props into<MobileDashboard>/<DesktopDashboard>. Both layouts stay client components; only the data source changes. Per #89, server data crosses as props, never through context, and is read at page level rather than in the layout.meals/page.tsxcomes too. It rendersuseMeals().todayMealsand throws the instantMealsProvideris deleted, so it cannot be a later slice. It is small and read-only: one server read, itsstatus === 'error'branch becomeserror.tsx, its skeleton becomesloading.tsx.(app)/layout.tsxloses both providers. It keeps one deliberate, written-down exception to #89's rule 4: it readsgetCurrentUser(), because identity does not vary by route.meal-log-drawer.tsx,weight-drawer.tsx,weight-history-row.tsx,goal-reached-overlay.tsxare all provider orapi-clientconsumers mounted inside(app)— including fromapp-sidebar.tsx, which is why they cannot be deferred.GoalReachedOverlayneeds no redesign (#89: it is already state-derived); it just receives props instead of callinguseMeals().- Server Actions in
dashboard/actions.tsfor weight create/update/delete and meal create. Per #90: react-hook-form keeps the form, the action is the write transport, dispatched insidestartTransitionwithisPendingas the only pending state; expected failures areActionResult<T>return values, never throws, applied into RHF throughsetErrorviaapplyActionError. DeletesonWeightSaved/onWeightUpdated/onWeightRemoved/refetchDashboard/retry/reloadKey. - Revalidation needs re-deciding against Next 16, not copied from this ticket's first draft: per #87,
revalidateTagnow requires a cacheLife profile and no longer re-renders on its own, andupdateTag/refreshare new.cacheComponentsis out of scope map-wide (#93), so theuse cachefamily is unavailable. Pick from what the previous caching model actually offers and say why in the PR. loading.tsx+ per-section<Suspense>replacestates.tsx;error.tsxreplacesDashboardError/InlineError. Per #92 the skeleton has the shape of its own route, and the sidebar paints instantly because the layout sits outside the boundary.use-dashboard-gate.tsgoes away — its "no goal block means not renderable" branch becomesrequireOnboarded(), which PR 1 already shipped.- Both providers are deleted outright (#89) — they hold server data and no UI state, so there is no reduced form to keep.
: the trigger keeps its own open/closed state and submits through an action.MealsProvidermay need to survive for the sidebar's "Log a meal" trigger - The weight-history drawer's on-open read passes the journal down from the page (#89) — the list is small. A Route Handler is the fallback if it ever isn't.
Out of scope
Auth of any kind — cookies, proxy.ts, gates and redirects all landed in PR 4 (#110). Profile and onboarding routes, meal edit/delete (#36's remaining half), and any backend change. Deleting api-client.ts and the transitional bridge is PR 8, not this one. npm test stays untouched; the e2e suite is blocking by now (#91) and this PR must keep it green — that is the entire reason the net went in first.
Done when
In the browser: a dashboard reload renders data with no loader flash and no client-side fetch in the network tab before hydration; editing a weight moves the trend, the change stat and the goal tile together with no invalidation wiring in the client; deleting is gone after F5; re-dating an entry outside the 6-week window removes it from the drawer immediately, and the phantom row from #75 cannot be reproduced. No useEffect fetch remains in the dashboard path, and /meals still renders after both providers are gone.
An expired session lands on — true, but PR 4 proves it, not this one./login via a server redirect, not a client bounce
Dependencies
Everything in #110's PRs 1 through 4 — the lib/server/ and lib/actions/ infrastructure, the e2e net, the freeze, and the session move. #75 is merged; #68 (PR #76) and #106 both land before the freeze, and all three touch desktop-dashboard.tsx, mobile-dashboard.tsx and dashboard-charts.tsx, which this rewrites. The dashboard is frozen from the freeze PR until this one merges — see #110 for the exact paths.
Supersedes the "adopt TanStack Query" idea: Server Actions plus server re-render cover the same ground, so don't do both.
Guidance
No new dependencies — this is framework surface, not a library. Every dashboard read is per-user and cookie-dependent, so it is dynamic and uncached; the win is streaming and removed round-trips, not cache hits, and the use cache family is unavailable to us anyway (#93).
One tension to settle: — settled by #90, and the answer is not the Next-idiomatic one. react-hook-form keeps the form; the Server Action is only the write transport. No progressive enhancement, no AGENTS.md's Forms section vs action={serverAction} + useActionStateaction={…} wiring, no useActionState, and formState.isSubmitting is never read again. Write against that, not against the framework docs' default form.
frontend/AGENTS.md is binding: read the relevant guide in node_modules/next/dist/docs/ before writing code or asserting an API — this Next is not the one in your training data. The facts this migration bets on are written up in docs/research/next-16-facts.md (#87).
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.