feat(next-up): show the next three deadlines, not one
A reader with two games switched on said the list lost its point at twenty-one rows and asked for the three closest deadlines up front. One row was also fragile on its own terms: ticking off the headline event left the panel pointing at something the reader had no context for. Three equal panels would be a stat grid, and a reader arrives with one question — so the shape is one answer at full size and two follow-ups under it, with no meter, summary or badges to turn the panel into a second copy of the list below it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6e95e54ec6
commit
41046899f9
+12
-3
@@ -23,7 +23,7 @@ import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/s
|
||||
import {
|
||||
advanceFocus,
|
||||
countByGame,
|
||||
firstToExpire,
|
||||
nextToExpire,
|
||||
outstanding,
|
||||
resolveFocus,
|
||||
} from "./state/lens.ts";
|
||||
@@ -40,6 +40,15 @@ import { metaFor } from "../shared/games.ts";
|
||||
|
||||
type View = "soon" | "timeline";
|
||||
|
||||
/**
|
||||
* How many deadlines the headline carries.
|
||||
*
|
||||
* One was the whole panel, and one is what a reader who has just finished it
|
||||
* needs replacing. Three is what they asked for: enough to plan an evening
|
||||
* around, few enough that the closest one still owns the page.
|
||||
*/
|
||||
const HEADLINE_DEADLINES = 3;
|
||||
|
||||
/**
|
||||
* Connection state. Offline is not an error here — the service worker serves
|
||||
* the last feed it saw and countdowns run off the local clock — but it does
|
||||
@@ -245,7 +254,7 @@ export function App() {
|
||||
* them on screen, not keep nagging me about them.
|
||||
*/
|
||||
const todo = outstanding(live, isDone, isIgnored);
|
||||
const next = firstToExpire(todo);
|
||||
const headline = nextToExpire(todo, HEADLINE_DEADLINES);
|
||||
|
||||
// Counted across every game the reader plays, not just the focused one — a
|
||||
// chip has to say what is waiting behind it to be worth tapping.
|
||||
@@ -356,7 +365,7 @@ export function App() {
|
||||
{view === "soon" ? (
|
||||
<>
|
||||
<NextUp
|
||||
row={next}
|
||||
rows={headline}
|
||||
focused={focus === null ? null : gameMeta(focus).name}
|
||||
onOpen={setOpenId}
|
||||
/>
|
||||
|
||||
@@ -5,29 +5,34 @@ import { Meter, URGENCY_COLOR } from "./Meter.tsx";
|
||||
|
||||
/**
|
||||
* The thesis of the page: this app is a clock, so the first thing you see is
|
||||
* the single event closest to expiring, at a size nothing else competes with.
|
||||
* the event closest to expiring, at a size nothing else competes with.
|
||||
*
|
||||
* Deliberately not a stat grid. One number, because the reader has exactly one
|
||||
* question on arrival.
|
||||
* The two behind it are listed under it, small. A reader asked for the three
|
||||
* next deadlines and he was right that one is too few — finishing the headline
|
||||
* event used to leave the panel pointing at something with no context — but
|
||||
* three equal panels is a stat grid, and the reader arrives with one question.
|
||||
* So the shape is one answer and two follow-ups, not three answers.
|
||||
*/
|
||||
export function NextUp({
|
||||
row,
|
||||
rows,
|
||||
focused,
|
||||
onOpen,
|
||||
}: {
|
||||
/**
|
||||
* The soonest-expiring event the reader has neither finished nor ignored.
|
||||
* A panel headed "next to expire" is a deadline they still have to meet, so
|
||||
* an event they already ticked off does not belong in it however visible
|
||||
* they have chosen to keep it elsewhere.
|
||||
* The soonest-expiring events the reader has neither finished nor ignored,
|
||||
* closest first. A panel headed "next to expire" is a list of deadlines they
|
||||
* still have to meet, so events they already ticked off do not belong in it
|
||||
* however visible they have chosen to keep them elsewhere.
|
||||
*/
|
||||
row: RowEvent | null;
|
||||
rows: RowEvent[];
|
||||
/** Name of the game being focused on, when the page is narrowed to one. */
|
||||
focused: string | null;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const gameMeta = useGameMeta();
|
||||
if (row === null) {
|
||||
const [lead, ...rest] = rows;
|
||||
|
||||
if (lead === undefined) {
|
||||
return (
|
||||
<section className="border-b border-hairline px-4 py-8">
|
||||
<p className="eyebrow">Nothing running</p>
|
||||
@@ -40,7 +45,7 @@ export function NextUp({
|
||||
);
|
||||
}
|
||||
|
||||
const { event, clock } = row;
|
||||
const { event, clock } = lead;
|
||||
const game = gameMeta(event.game);
|
||||
const heat = URGENCY_COLOR[clock.urgency];
|
||||
const known = clock.msRemaining !== null;
|
||||
@@ -92,7 +97,66 @@ export function NextUp({
|
||||
label={`${event.title} time remaining`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{rest.length > 0 && (
|
||||
<div className="mt-5 border-t border-hairline pt-3">
|
||||
<p className="eyebrow">Then</p>
|
||||
<ul className="mt-1.5">
|
||||
{rest.map((row) => (
|
||||
<QueuedRow key={row.event.id} row={row} onOpen={onOpen} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A deadline waiting behind the headline.
|
||||
*
|
||||
* Deliberately a different object from an `EventRow`: no meter, no summary, no
|
||||
* badges. Its whole job is "what is after this one, and how long have I got" —
|
||||
* anything more turns the panel into a second copy of the list it sits above.
|
||||
*/
|
||||
function QueuedRow({
|
||||
row,
|
||||
onOpen,
|
||||
}: {
|
||||
row: RowEvent;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const gameMeta = useGameMeta();
|
||||
const { event, clock } = row;
|
||||
const game = gameMeta(event.game);
|
||||
const known = clock.msRemaining !== null;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(event.id)}
|
||||
className="group flex w-full items-baseline gap-2.5 py-1.5 text-left"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-1.5 shrink-0 translate-y-[-1px] rounded-full"
|
||||
style={{ background: game.hue }}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] leading-snug text-muted transition-colors duration-150 group-hover:text-ink">
|
||||
<span className="sr-only">{game.name}: </span>
|
||||
{event.title}
|
||||
</span>
|
||||
<span
|
||||
className="tnum shrink-0 font-display text-xs font-semibold"
|
||||
style={{
|
||||
color: known ? URGENCY_COLOR[clock.urgency] : "var(--color-faint)",
|
||||
}}
|
||||
>
|
||||
{known ? formatRemaining(clock.msRemaining ?? 0) : "no end date"}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user