web: give the landing hero a column of the day's best-rated recipes

The hero was a single block of copy with nothing beside it, so a visitor landing
on the page saw no photograph at all until they scrolled. It now splits into
copy + an award card: the highest-rated photos of the current window, one frame
per photo, each labelled for the window it came from.

The frame set is the day's top-rated first, then the week's, both deduped by
photo id, so a photo that is both this day's and this week's best is drawn once
and keeps the day's label — that is the tighter of the two windows and the more
specific claim. Measured on the live data (2026-09-23 UTC, week = ISO Monday
2026-09-21): GET /api/highlights came back with day [] and week a full five
frames (ids 12,13,51,50,3, every one avg 5, n 1). The day window is empty simply
because no rating had landed since 00:00 UTC, and an empty day window must not
empty the column — hence the union rather than a fallback: whatever each window
has, merged, deduped.

Frames change the way the film strip already does, so the card reuses that
machinery rather than inventing a second one: the same .lp-arrow dots and the
same FrameArrows component, which already renders nothing under two frames.
Under two frames the card also keeps still — no arrows and no timer, because a
single frame has nothing to advance to and a timer that swaps a frame for itself
is just a repaint. Five seconds a frame, one second of crossfade: all frames are
stacked in the same box and the active one is the only one at opacity 1, each
transitioning its own opacity over 1s, so the outgoing frame fades out over the
same second the incoming one fades in and the box never flashes empty. Measured
in the built app: mid-step opacities 0.32, 0.68, 0.00, 0.00, 0.00 at the halfway
point of a step, and transitionDuration exactly 1s on every slide. Stepping by
hand restarts that clock instead of letting the old 5s fire on top of the new
frame — an arrow step to 2 then waited 4.2s still sat on that frame, where
without the restart it would have moved on at 5s from the previous frame's
start.

The rating is shown on the frame because it is the whole reason the frame is
there: avg to one decimal, plus the vote count as "1 vote"/"N votes" — one
decent vote and one outstanding vote are not the same window, and the reader
can tell them apart at a glance. Score is mono, bottom-left, over a text
shadow.

Backend side this is one query and one route. topRatedPhotos(since, limit)
joins ratings to photos on CAST(substr(ratings.key, 7) AS INTEGER), since
ratings keys are the strings "photo:<id>"; it filters ratings.key LIKE 'photo:%'
so a look: vote can never award a frame — there is no look to show — and
photos.consent = 1, so a photo pulled from public display is pulled from the
awards with it. Ordering is avg DESC, n DESC, at DESC, id DESC: best average,
then the better-supported average when averages tie, then the freshest, then id
only to make the order total and the frame set stable between requests. avg
comes back rounded to 2dp. GET /api/highlights computes the two windows in UTC
— midnight, and ISO Monday midnight via midnight - ((getUTCDay()+6)%7)*86400000
— and returns { highlights: { day, week } }. HIGHLIGHT_LIMIT is 5.

The column is 340px on the right of the copy, stacking under it below 980px.
Measured on the built app at 1280px: copy ends at 902, card starts at 948, same
hero row, card exactly 340px wide, five slides for five frames, label "Recipe
of the week", score "5.0★1 vote", the two arrows the only .lp-arrow inside
.lp-award, meta #CLASSIC_VIVIDIPES. At 900px the card sits under the copy. With
the window forced to one frame the card draws 0 arrows and 1 slide; with both
windows empty there is no .lp-award and the hero is not split at all, so an
unrated install looks exactly as it did before.

Verified:
  award-column-probe.cjs (new, scratchpad) — geometry, arrows, crossfade
    opacities, the 5s auto step, the manual step's clock restart, the one-frame
    and no-frame windows. 18/18 on http://localhost:8090.
  backend npm test — 166 passed, 0 failed, with four new checks in the ratings
    section: a vote lands in today's and this week's window, a look: subject is
    never an award, and a photo drops off the awards once deleted. The ratings
    and photos suites cover the joins the new query leans on.
  landing-test.cjs, lp-arrows-test.cjs 33/33, landing-rating-test.cjs 21/21,
    landing-photo-guard-test.cjs 21/21 — 0 fail against the built app.
  web tsc --noEmit clean. Dark and light themes both eyeballed on the built
    app (award-hero-dark.png, award-hero-light.png).

ponytail: the card re-fetches on the page's own reload() rather than polling, so
a rating cast while the tab sits open will not surface until the next reload;
the awards are a landing-page flourish, not a live feed — when they need to be
live, poll the same route on the timer the frames already run. The card's
box-shadow is the dark card's, one rgba(0,0,0,0.35), and reads heavy on the
light theme next to .lp-recipe-card's light-specific shadow; left alone rather
than adding a token for one property.
This commit is contained in:
2026-09-23 18:17:50 +07:00
parent 81ed53cf78
commit 3f5d2cd017
6 changed files with 251 additions and 19 deletions
+25
View File
@@ -799,6 +799,31 @@ export function rateLook(key: string, visitor: string, stars: number): void {
).run(key, visitor, stars, new Date().toISOString());
}
// What a window of votes left on one photo — the landing's award column reads
// the best of these. `at` is the window's most recent vote, the last tie-break.
export type PhotoTally = { id: number; avg: number; n: number; at: string };
// The best-rated consenting photos a window of votes names, most deserving
// first. Only `photo:` subjects count: a vote on a built-in look is not a
// contribution and has no frame to show. The key carries the id, so the join is
// a cast of everything past `photo:` — a key that casts to no row simply drops
// out of the join. Order: the mean, then how many votes stand behind it, then
// the most recent one, so a tie is settled by evidence and two loads of the page
// cannot disagree.
export function topRatedPhotos(since: string, limit: number): PhotoTally[] {
const rows = db
.prepare(
`SELECT photos.id AS id, AVG(ratings.stars) AS avg, COUNT(*) AS n, MAX(ratings.at) AS at
FROM ratings JOIN photos ON photos.id = CAST(substr(ratings.key, 7) AS INTEGER)
WHERE ratings.key LIKE 'photo:%' AND ratings.at >= ? AND photos.consent = 1
GROUP BY photos.id
ORDER BY avg DESC, n DESC, at DESC, id DESC
LIMIT ?`,
)
.all(since, limit) as PhotoTally[];
return rows.map((r) => ({ id: r.id, avg: Math.round(r.avg * 100) / 100, n: r.n, at: r.at }));
}
// Every subject's tally, keyed by subject. `mine` is this visitor's own vote, 0
// when they have not rated it — the landing draws the star row from it.
export function ratingsFor(visitor: string): Record<string, Rating> {
+19
View File
@@ -49,6 +49,7 @@ import {
setUserBlocked,
setUserPassword,
setUserRemoved,
topRatedPhotos,
updateRecipe,
updateUserEmail,
userAvatar,
@@ -428,6 +429,24 @@ app.post('/api/ratings', async (req, reply) => {
return reply.status(200).send({ key, rating: ratingsFor(visitor)[key] });
});
// The landing hero's award column: the day's best-rated contribution and the
// week's, read off the same votes the strip casts — a window instead of a
// lifetime. The windows are the server's clock in UTC, so every visitor and
// every cache agrees on which frame is today's; the week starts on Monday, the
// ISO week the page's own copy implies. Public like the tally it is drawn from.
const HIGHLIGHT_LIMIT = 5;
app.get('/api/highlights', async () => {
const now = new Date();
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const monday = midnight - ((new Date(midnight).getUTCDay() + 6) % 7) * 86_400_000;
return {
highlights: {
day: topRatedPhotos(new Date(midnight).toISOString(), HIGHLIGHT_LIMIT),
week: topRatedPhotos(new Date(monday).toISOString(), HIGHLIGHT_LIMIT),
},
};
});
app.post('/api/auth/signup', async (req, reply) => {
const b = bodyOf(req);
if (!b) return reply.status(400).send({ error: 'invalid body' });