FaithCare Dashboard
Replaces the spreadsheet-and-sticky-notes system churches use to track first-timers, so no one who walks through the door gets forgotten.

Church growth teams lose people at the exact moment they're most reachable: right after a first visit, when follow-up needs to happen fast and consistently. In practice that tracking usually lives in someone's notebook or a spreadsheet no one else checks. FaithCare replaces that with a real operations dashboard built for church admin teams: first-timer and second-timer tracking, prioritised follow-up queues, community groups, salvation records, and prayer requests, all in one place, with bulk CSV/Excel import so existing member data isn't lost in the switch. For individual members, the same platform doubles as a personal growth tool with a sermon journal, a focus timer, and streak tracking, giving the church a reason for members to open the app between services, not just show up to them.
The app is a Vite-bundled React SPA deployed to Vercel with a catch-all rewrite routing all paths to index.html. The short-lived JWT access token lives exclusively in a JavaScript module-scoped variable (never localStorage), providing XSS resistance. The long-lived refresh token is stored in an HTTP-only cookie. On any 401, a single-flight refresh mechanism blocks concurrent calls while queued requests are held in a subscriber array, then replayed with the new token once resolved. Data fetching uses TanStack Query v5 with query keys namespaced by organizationId and userId to prevent cross-tenant cache hits. The global cmdk command palette queries four API endpoints in parallel on open, and a SearchContext filters already-cached data client-side with zero per-keystroke API calls.
Auth Security
Chose
In-memory JWT access token with HTTP-only cookie refresh token
Why
Storing access tokens in localStorage exposes them to any XSS script on the page. Keeping the access token in a module-scoped variable means it is never accessible from the DOM. The HTTP-only cookie carries the refresh token so the browser never exposes it to JavaScript at all.
Trade-off
The in-memory token is lost on hard refresh, requiring a /auth/refresh call on every page load — one extra network round-trip before the app renders protected content.
Token Refresh
Chose
Single-flight token refresh with subscriber queue in the shared apiRequest wrapper
Why
When an access token expires, multiple in-flight requests can simultaneously receive a 401. Without coordination, each would independently try to refresh, causing race conditions and potentially invalidating a fresh token with a redundant refresh call.
Trade-off
The subscriber queue adds statefulness to what would otherwise be a pure fetch wrapper. If the refresh itself fails, all queued requests receive the original 401 and must handle it individually.
Component Library
Chose
shadcn/ui + Radix UI primitives over MUI
Why
shadcn/ui generates unstyled, composable Radix primitives into the project source, giving full ownership over markup and styles. MUI was evaluated and rejected because its Emotion-based styling system would have required fighting the library to achieve the required visual output.
Trade-off
Every primitive (Dialog, Select, Command, Sheet) required manual composition and accessible wiring, increasing initial build time compared to a fully-styled library.
Search Architecture
Chose
Client-side global search via SearchContext filtering already-cached query data
Why
The main list views are already fetched by TanStack Query on page load and held in cache. Wiring search to server-side API endpoints would add debounced network calls on every keystroke and require managing additional loading states in the UI.
Trade-off
Search is bounded by what is already in the client cache. For organizations with very large member datasets where the API paginates results, the search will only match visible records.
Persona Routing
Chose
localStorage userType flag for persona routing instead of server-enforced role guards
Why
The backend returns a role field on the auth response. Reading this into localStorage as a derived userType string at login time was the fastest path to rendering the correct sidebar and dashboard without adding a dedicated roles endpoint call.
Trade-off
The userType in localStorage can be tampered with client-side, which could expose organization UI to individual users. All API endpoints enforce authorization server-side, so the risk is a confused UI state, not a data breach.
Replace localStorage userType persona routing with server-side role claims read from the decoded JWT, removing the client-tampering surface entirely
Add server-side paginated search with debouncing for first-timers and follow-ups to handle large church datasets beyond what fits in a single page response
Implement WebSocket or SSE for real-time follow-up due-date alerts instead of requiring a manual page refresh to see updates from other admins
Extract the token-refresh subscriber queue into a standalone service worker or broadcast channel so multiple tabs share a single refresh cycle