App-side engineering lessons from building the React (Vite) client — the ones about UI behavior and component design rather than deployment. For the deployment, Azure, CI/CD, database, and config gotchas, see Lessons learned.
- The "post back" when moving a card was a full reload, not a real page post. Dragging a card between lanes called the API and then re-fetched the whole board, so the UI visibly flashed/reset — it looked like a postback. The fix is optimistic UI:
useTodos.moveCardupdates local state immediately, then reconciles just the affected card from the server response, and only falls back to a fullreload()on error. The board never flashes on the happy path, and a failed move rolls back cleanly. - Keep the optimistic update and the server reconcile in one place. All todo mutations (move, create, update, delete) live in the
useTodoshook, not in the view. The view (KanbanBoard) just renders and calls hook methods — so the optimistic/rollback logic is written once and is unit-testable. - On error, reload before setting the error message.
reload()callssetError('')internally, so if you set the error first and reload after, the reload wipes your message. Order matters:await reload(); setError(err.message);— otherwise the user never sees why the action failed. (This is exactly the bug theuseTodos"reverts by reloading when a move fails" test now guards.) VITE_*env vars are build-time, so anything the SPA needs from config (VITE_API_URL,VITE_GOOGLE_CLIENT_ID) must be set as GitHub repository Variables and baked in at build — see the production-500 triage in lessons.md for how a wrongVITE_API_URLshows up.
Symptom: dragging a card between lanes works fine in a desktop browser but does nothing on a phone browser — the cards can't be picked up at all.
Root cause: the board was built entirely on the native HTML5 Drag and Drop API — draggable cards with onDragStart + e.dataTransfer, and onDrop lanes — with no touch fallback. Those dragstart / dragover / drop events are mouse-only; mobile browsers don't synthesize them from touch gestures, so on a phone nothing fires. It's a known limitation of the API, not a device or deploy problem.
Options considered:
- Switch to a touch-aware drag library (
@dnd-kit, orreact-dndwith a touch backend). Built on Pointer Events, so real drag-and-drop works with mouse, touch, and keyboard, and it's accessible. The proper long-term fix, but a larger rewrite of the board's drag wiring plus a new dependency. - Add a tap-to-move control — a small button on each card that opens the other lanes as tap targets and calls the existing
moveCard(id, status). Works on touch and mouse, tiny change, no new dependency. [chosen] - A touch polyfill (
mobile-drag-drop) that shims HTML5 DnD onto touch events. Smallest code change, but janky and less reliable.
Decision — option 2 (tap-to-move), because:
- It's the smallest, lowest-risk change: two component files, no new dependency,
package.jsonuntouched. - It reuses
moveCard(), so a tap-move keeps the same optimistic update + concurrency handling as a drag — one code path for every device and every method. - It works identically on phone and desktop, because the control is a plain button and a button's
clickfires the same on a finger tap as a mouse click — which is exactly why it sidesteps the mouse-only DnD limitation. - Dragging on a small screen is fiddly even when it works, so tap-to-pick-a-lane is arguably better mobile UX than real drag.
- Native drag on desktop is left untouched, so desktop users get the tap control in addition to dragging.
Implementation: TaskCard.jsx gained a ⇄ "move" button that reveals the other lanes (excluding the card's current one, each calling onMove(todo.id, status)); Lane.jsx passes onMove={onDropCard} (i.e. moveCard) down to each card. No change needed in KanbanBoard.
Trade-off accepted: actual dragging still doesn't work on touch — only the tap control does. If true drag-on-touch is ever wanted, revisit option 1 (@dnd-kit).
Symptom: editing a due date, Backspace only cleared the segment the caret was on (month, day, or year) and wouldn't carry across — from the year you couldn't backspace back into the month, and clearing the field entirely was awkward.
Root cause: the native <input type="date"> is segmented (mm | dd | yyyy), and each segment is its own edit context — the browser owns that behavior, so Backspace/Delete are per-segment by design. A controlled React binding made partial edits worse: clearing a segment left the value momentarily "incomplete", the input reported empty, onChange fired with '', and React reset the field. There's no way to get continuous editing out of the native control.
Fix: a small reusable DateField component that replaces the native input with a masked text field:
- Typing digits auto-inserts the
/separators (07192026→07/19/2026), and the caret is preserved across reformatting so Backspace and Delete flow across the whole field (year → month, through the slashes) and it can be cleared entirely. - It validates on completion — an impossible date like
02/30is treated as empty. - A mini-calendar button sits inside the bar; clicking it opens the browser's native picker via
HTMLInputElement.showPicker()(with a focus fallback), and picking a day fills the bar. - It takes and emits a plain
yyyy-mm-ddstring, so the surrounding form/save logic is unchanged.
Lesson: native <input type="date"> is great for free validation and the calendar, but its segmented editing can't be made to behave like free text. When continuous typed editing matters, own the input as a masked text field (and re-add the calendar via showPicker()) rather than fighting the native control. Used in TodoForm and TaskCard.
Symptom: on a phone browser (Chrome / Samsung Internet), choosing light mode didn't produce a light page — it flashed light for an instant and snapped back to dark. Desktop browsers, including desktop Chrome, were fine, and the in-app toggle worked there.
Root cause: Chrome for Android's Auto dark theme (and Samsung Internet's dark mode) algorithmically invert light pages at paint time when the OS is in dark mode. When the user toggles the app to light, the page renders light and the browser immediately re-darkens it — that is the "flash, then dark" you see. Opting out requires an explicit signal the browser honors, and the app wasn't sending it.
The fix that actually works — the only keyword. Per Chrome's
Auto Dark Theme docs, the opt-out is
color-scheme: only light. Plain light dark (or plain light) does not opt a light page
out of force-dark — it only says "I support both," and the browser still darkens the light state.
src/index.css— the light state's:rootusescolor-scheme: only light; the dark theme keepscolor-scheme: dark. So light mode tells the browser "render light, do not auto-darken," while dark mode is unaffected.index.html— a<meta name="color-scheme">in<head>provides the signal before the JS-injected stylesheet loads (reduces the load flash).
A wrong turn worth recording. The first attempt used color-scheme: light dark, validated
against headless Chromium's auto-dark emulation (Emulation.setAutoDarkModeOverride), which rendered
light and looked fixed. Real Chrome Android still force-darkened it — the emulation's override is
more lenient than the shipping feature. The emulation is good for reproducing the bug, but the
opt-out must be confirmed on a real device, and the documented only keyword is what actually counts.
The breakthrough — fresh load vs. cache. The fix was confirmed the moment a fresh page load (a
newly relaunched browser tab, bypassing cache) rendered light with the browser's auto-dark left on,
while the same URL opened from an existing/cached entry point still came up dark. That one comparison
did two things at once: it proved color-scheme: only light works on a real device with auto-dark
active, and it isolated every remaining "still dark" report as a caching problem (a stale
index.html), not a code problem — which led directly to the cache-header fix below.
Caveat — in-app browsers can't be overridden. This fix stops force-dark in real browsers
(Chrome, Safari, Edge). It does not help inside an app's in-app browser — e.g. tapping the link
from the LinkedIn or Facebook mobile app opens their built-in browser (Android System WebView), which
the host app can force-dark regardless of any color-scheme signal. That darkening hits every website
opened in-app, not just this one, and no site-side change can prevent it; opening the link in a real
browser renders it correctly.
Lesson: to keep a light theme from being force-darkened by mobile auto-dark, the light state must
declare color-scheme: only light — the only keyword is the operative part; light dark is not an
opt-out. Verify on a real device, and accept that in-app WebViews are outside a website's control.
Symptom: in dark mode the "Sign in with Google" button stayed a bright white pill, clashing with the dark card.
Root cause: the button is a Google-rendered widget (Google Identity Services). The app's CSS
variables can't restyle it — the only lever is the GIS theme option, which was hardcoded to
outline (the light variant) and read once at mount, so it never tracked the app theme. GIS
offers outline, filled_blue, and filled_black; none of them follow prefers-color-scheme.
Fix (contained to GoogleButton.jsx):
- Derive the theme from the current mode —
theme: dark ? 'filled_black' : 'outline'— computingdarkthe same wayThemeToggledoes (<html data-theme>, falling back tomatchMedia). - Because GIS won't restyle an already-rendered button, a
MutationObserverondocument.documentElement'sdata-themeclears the container and callsrenderButtonagain when the theme flips (plus amatchMedialistener for the system-preference case). Clear the container first, or you get a stacked/duplicate button.
Follow-up — the button changed length on each toggle. The re-render measured its width from the container after clearing it (momentarily empty), so each redraw came back a slightly different size. Fixed by measuring a stable width from the surrounding box before clearing and caching it, so every re-render uses the same width.
Lesson: third-party rendered widgets don't inherit your theme via CSS — pass their own theme option
and re-render them when your theme changes, but re-render from a stable measurement so the widget
doesn't jump. (Verifiable only with a real VITE_GOOGLE_CLIENT_ID against a running app, since the
button is Google-rendered.)
Symptom: after deploying a frontend fix, a phone kept showing the old build when the site was opened from an existing link (e.g. a LinkedIn post), even though a fresh load in Chrome showed the new build. Clearing Chrome's cache didn't fully resolve it.
Root cause & fix — cache headers. Vite fingerprints /assets/*.js and .css (a new filename
every build), so those are safe to cache forever; the real risk is a cached index.html pinning a
returning visitor to old asset URLs. The deploy set no cache policy, leaving it to defaults. Fixed in
frontend/staticwebapp.config.json:
globalHeaders→Cache-Control: no-cache, so the HTML shell is always revalidated and a returning visitor picks up new deploys immediately.- a route override for
/assets/*→Cache-Control: public, max-age=31536000, immutable, so the fingerprinted bundles still cache long-term.
Caveat — in-app browsers keep their own cache (and their own dark mode). Apps like LinkedIn and Facebook open links in a built-in browser (Android System WebView) with its own cache, separate from Chrome — so clearing Chrome's cache doesn't touch it, and it may serve an old copy until its own cache expires. This is the same in-app browser that can force-dark pages (see the dark/light-mode note above): the darkening and the caching are the host app's behavior, not the website's, and a site can neither clear that cache nor override that dark mode. Opening the link in a real browser (Chrome, Safari, Edge) is the reliable path, and it's what was verified working.
Lesson: ship an SPA with no-cache on the HTML shell and immutable on fingerprinted assets so
deploys propagate to returning visitors; and remember in-app browsers (LinkedIn, Facebook, etc.) have
independent caches and dark-mode behavior a website can neither clear nor override.
Symptom: the first request after the app had been idle failed instantly with "Failed to fetch" (or a 502/503/504) — because Azure's Free App Service unloads the API after ~20 min and the serverless database suspends when idle, so the first hit has to wake them (with Postgres on Neon the database part is now a second or two).
Fix (client side): all requests go through a wakeFetch wrapper in
src/lib/apiClient.js that retries network errors and 502/503/504 with exponential
backoff (~50s) and surfaces a "Waking the server up…" message (App.jsx,
AuthForm.jsx) instead of failing immediately. Other codes (400/401/403/404/409/500)
are not retried, so real errors still fail fast.
Lesson: on a sleep-when-idle host, treat the network error and the gateway codes
(502/503/504) as "warming up — retry with a message," but never the app's own error
codes. The full story (why it happens, the server-side DB retry, and the keep-warm
GitHub Action + free-tier notes) is in
Cold starts on the free tier.
