Rating saves in place, and TVLens takes its first script
A browser never tells the server where a page was scrolled to, so restoring a rater’s position and not reloading at all cost exactly the same thing: a script. Rating now POSTs over fetch and the page never navigates, with the plain form submit intact underneath as the fallback.
Context
Rating a show is the one interaction the whole product rests on. It feeds Top Picks (#15), unlocks Side Quests (ADR-09), and is the cold-start data Layer 2 reads (ADR-08). Somebody rating is usually rating several shows in a sitting.
Two fixes reached the widget in one day.
#12 fixed how it draws: a font fallback
clipped the star glyph, and a :hover rule that sticks on touchscreens wiped the saved rating
to a 35%-alpha preview. #18 replaced
ten radios and a Save button with ten submit buttons, so clicking a star POSTs that score
directly. Both were server-rendered and needed no script.
Neither fixed the shape of it. The POST is still a POST: a plain form submit, a redirect back, a fresh navigation, and a landing at the top of the document. Rate a show and the page reloads and lands somewhere else.
I tried twice to fix that on the server. Both attempts are below as alternatives, because the second one shipped briefly and the reason it failed is the reason this record exists.
A browser never tells the server where the page was scrolled to. No header, no field, nothing. Restoring a position means recording it on the client first, which is a script.
So “put me back where I was” and “do not reload at all” cost the identical thing, and only one of them also removes the flash. The choice was never between a script and no script. It was between a script that reloads and a script that does not.
Decision
Rating POSTs over fetch and the page does not navigate. The script is progressive
enhancement: with JavaScript off, the widget is exactly what #18 shipped and still works.
The decision has four parts.
The endpoint answers two ways. rate() returns JSON when the request carries
X-Requested-With: fetch, and redirects as before otherwise. The scale bounds, the half-step
check and login_required are untouched and apply on both paths: this is a public endpoint and
the guards were never client-side.
Django renders the average sentence and returns it as HTML. Rating a show changes
“★ 4.2 average from 12 ratings on TVLens” directly under the stars, so an in-place update that
only moved the stars would leave a visibly wrong number on screen. That sentence now lives in
templates/shows/_rate_meta.html, included by the detail page and re-rendered into the JSON
response. The copy exists once, in Django, and is never rebuilt in JavaScript.
No flash message on the in-place path. messages.success assumes something is about to
navigate. Queued with nothing to render it, it would surface later on an unrelated page. The
.your-score readout beside the stars is the confirmation, and it is more legible than a
banner at the top of a page the user is not looking at.
Every failure falls back to the plain submit. No fetch, no FormData, a non-2xx
response, a network error: the script rebuilds the score as a hidden input and submits the form
normally. form.submit() does not carry a submit button’s own name and value, which is the one
sharp edge here, and the script handles it explicitly. The worst case is the behaviour this ADR
replaces.
What this reverses
The README says “server-rendered with no JS framework”, and that is still true. But the
codebase had zero JavaScript, and comments in detail.html asserted “No JS anywhere in
TVLens” as a property. That claim is now false, and this change corrects those comments.
Worth being precise about what was and was not given up. This is a script, not a framework: about 45 lines, inline, no dependency, no build step, no bundler, no vendored file. Every page including this one is still server-rendered, and the detail page is fully functional with JavaScript disabled. What changed is that TVLens is no longer able to say it has none, and the next request to “just add a little JS” no longer meets a bright line. That is the real cost, and it is a cultural one rather than a technical one.
Alternatives, and why they lost
A. Keep the reload and land at the top of the page. The original behaviour. Correct, and it loses the user’s place on every rating. For a repeated action this is the wrong shape however fast the server is.
B. Redirect to #rate. Shipped, briefly. The fragment does put the widget on screen, but
it puts it flush against the top of the viewport, and the rating panel is short: heading,
stars, one line of meta. Everything below it fills the rest of the screen, and what is below it
is “More shows like this”. I read the result as having been thrown into the recommendations,
which is exactly what it looks like. A fix that relocates a problem is not a fix.
C. scroll-margin-top on the panel. Also tried: land the panel a third of the way down
instead of at the very top. Better framing, and still an approximation of a position the server
cannot know. The request was to return to the actual position, and this cannot do that at any
value.
D. Record the scroll offset in a hidden field and restore it after the redirect. The only
remaining way to satisfy “put me back where I was”. It needs JavaScript to read
window.scrollY, so it pays this ADR’s whole cost, and it still reloads the page. Same
price, worse outcome.
E. Turbo, htmx, or similar. These solve this properly and generally. They are also a dependency, a vendored asset or a CDN, and a build decision, taken on behalf of one interaction. If TVLens later wants this behaviour in several places, this is the alternative to revisit, and revisit it deliberately rather than arriving at it by accretion.
F. Rebuild the average sentence in JavaScript. Avoids the extra template. It also puts user-facing copy in two languages in two files, where they drift silently. Returning rendered HTML costs one small partial and keeps one source of truth.
After Action Review
Still open. Four things are worth watching once this has been lived with for a while:
- The home page rows do not update. Rating from a detail page changes Top Picks and Side Quests, but the user is on the detail page and will not see it until they navigate back. That is correct and it is also the first thing that will look like a bug if the row is stale in a demo. If it grates, the answer is not to widen the script but to decide whether those rows should be reachable from here at all.
- Whether anything ever exercises the fallback. It is written and tested at the endpoint, but the client-side path is not covered by the Django test suite. If TVLens ever grows a browser test, this is the first thing it should cover.
- Whether this stays one script. The line was “no JS”; the line is now “one script, no framework, everything works without it”. That is a much softer boundary, and the next feature that would be easier with a little more will test it.
- The
X-Requested-Withconvention. Chosen because it is one header and needs no content negotiation. If a second endpoint ever needs this, decide then whether to keep the header or move toAccept: application/json, rather than having both.
Tested in shows/tests.py::RatingTests: the in-place path answers 200 with the score and the
re-rendered average instead of redirecting, it queues no flash message, and the scale guards
still reject a bad score over the same path. The plain path still redirects to #rate.