feat(timeline): make it a board, with the axis and the names pinned
The reader who was asked what good looks like named paimon.moe's timeline, and the thing ours was missing is what that view never loses: on a wide window we drew more calendar and nothing that said which day, whose game, or which event was on screen. The date axis, the lane names and the event names all scrolled away together — and a six-week bar starts weeks off the left edge, so its name went with its start date and left a coloured rectangle. So it is a pane that scrolls in both directions, with the axis stuck to the top and every name stuck to the left. A name is sticky inside its own bar, so it can never wander onto an event it does not belong to. The axis gained the week the schedules are actually written in: a dated tick every Monday and a brighter rule at each month. The frozen label column that started this went away again — it stood on the calendar and ate the names it was meant to keep. Lane names sit on their own line instead, and "jump to today" sits in the board's header rather than floating over the dates it sends you back to. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1cd9579280
commit
e489697540
@@ -100,7 +100,7 @@ src/
|
|||||||
EventRow.tsx row + meter + caption (F2, F3)
|
EventRow.tsx row + meter + caption (F2, F3)
|
||||||
Meter.tsx the depletion meter
|
Meter.tsx the depletion meter
|
||||||
Legend.tsx what the bars and colours mean
|
Legend.tsx what the bars and colours mean
|
||||||
Timeline.tsx calendar lanes (F1)
|
Timeline.tsx the board: pinned axis, lanes, bars (F1)
|
||||||
EventDetail.tsx detail sheet, ignore action (F9)
|
EventDetail.tsx detail sheet, ignore action (F9)
|
||||||
ProgressControls status, effort, note (F12)
|
ProgressControls status, effort, note (F12)
|
||||||
Dailies.tsx today's strip, per-game reset clocks
|
Dailies.tsx today's strip, per-game reset clocks
|
||||||
|
|||||||
+5
-3
@@ -307,8 +307,10 @@ diagnosis in each item still holds — what changed is whether it has been acted
|
|||||||
| P1b Calendar → Timeline | **Done.** The tab reads "Timeline" |
|
| P1b Calendar → Timeline | **Done.** The tab reads "Timeline" |
|
||||||
| P1c more games | **Done, four of them.** Infinity Nikki, Persona 5: The Phantom X, Reverse: 1999, Blue Archive — one commit each. Of the games named in the thread, Azur Lane and Umamusume are **declined on conduct** rather than pending, and the declined and cleared-but-unbuilt candidates are recorded in `AGENTS.md` § Scraping conduct so they are not re-litigated |
|
| P1c more games | **Done, four of them.** Infinity Nikki, Persona 5: The Phantom X, Reverse: 1999, Blue Archive — one commit each. Of the games named in the thread, Azur Lane and Umamusume are **declined on conduct** rather than pending, and the declined and cleared-but-unbuilt candidates are recorded in `AGENTS.md` § Scraping conduct so they are not re-litigated |
|
||||||
| P1c roadmap in the colophon | **Not done** |
|
| P1c roadmap in the colophon | **Not done** |
|
||||||
| P2 colour and screenshot | **Not done** |
|
| P2 colour and screenshot | **Layout done** (2026-08-18), colour untouched. The palette was left alone deliberately — the two-axis system was not what made the screenshot read as unfinished, a dense single column on a wide screen was. Past `lg` the page is now a pinned deadline rail beside the lists, the timeline is a board with its axis and names pinned, and the footer and settings are columns. The screenshot still wants reshooting |
|
||||||
| P2 custom events | **Done.** PRD F13 first, then the code; `mygame:` / `myevent:` key spaces, in the export |
|
| P2 custom events | **Done.** PRD F13 first, then the code; `mygame:` / `myevent:` key spaces, in the export |
|
||||||
|
|
||||||
So the outstanding work is P1b's three client changes, the colophon roadmap, the colour pass, and
|
So the outstanding work is the colophon roadmap, P0's build assertion, and a reshoot of the promo
|
||||||
P0's build assertion — the cheapest items on the list are the ones still open.
|
screenshot. P1b is closed. The colour pass is open in name only: the palette was examined and left
|
||||||
|
alone with the reasoning above, so what is left of P2 is the screenshot, which is now worth taking
|
||||||
|
from a wide window.
|
||||||
|
|||||||
+13
-6
@@ -63,7 +63,7 @@ no stored key. A game may have several sources; see `docs/INGESTION.md` § Three
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
**F1 — Calendar view (default).**
|
**F1 — Timeline view.**
|
||||||
A horizontal timeline, one lane per game, spanning a scrollable date range with "today" pinned as a
|
A horizontal timeline, one lane per game, spanning a scrollable date range with "today" pinned as a
|
||||||
vertical marker. Each event is a bar from `startsAt` to `endsAt`. Bars are colored by game, and
|
vertical marker. Each event is a bar from `startsAt` to `endsAt`. Bars are colored by game, and
|
||||||
completed events render at reduced opacity with a check. Clicking a bar opens a detail panel with
|
completed events render at reduced opacity with a check. Clicking a bar opens a detail panel with
|
||||||
@@ -72,22 +72,29 @@ title, type, exact start/end in the user's local timezone, source link, and a co
|
|||||||
An event with `endsAt: null` renders as a bar with a frayed right edge and the label "end date
|
An event with `endsAt: null` renders as a bar with a frayed right edge and the label "end date
|
||||||
unknown" — it must be visually distinct from an event that ends far in the future.
|
unknown" — it must be visually distinct from an event that ends far in the future.
|
||||||
|
|
||||||
|
It is a board rather than a stretch of page: its own pane, scrolling in both directions, with the
|
||||||
|
date axis pinned to the top and every name — the lane's and the event's — pinned to the left edge.
|
||||||
|
Those three used to scroll away together, which made a wide window worse rather than better: more
|
||||||
|
calendar on screen, and nothing left saying which day, whose game, or which event was being read. A
|
||||||
|
six-week bar starts weeks off-screen, so a name that rides off with its own start date leaves a
|
||||||
|
coloured rectangle behind.
|
||||||
|
|
||||||
**F2 — Ends-soonest list.**
|
**F2 — Ends-soonest list.**
|
||||||
A flat list of all *currently running* events sorted ascending by end date, with a relative
|
A flat list of all *currently running* events sorted ascending by end date, with a relative
|
||||||
countdown ("ends in 2 days", "ends in 4 hours"). Under 24 hours, the row is emphasized. This is the
|
countdown ("ends in 2 days", "ends in 4 hours"). Under 24 hours, the row is emphasized. This is the
|
||||||
view that justifies the app, and it is one tap from the timeline.
|
view that justifies the app, and it is one tap from the timeline.
|
||||||
|
|
||||||
**The list is capped and offers the rest.** Two games already run to twenty-one live events, and a
|
|
||||||
reader who tried exactly that said the list stopped being usable. Each section shows a handful with
|
|
||||||
an explicit "show all N". This truncates the *view* only: the order is untouched, and the rows below
|
|
||||||
the cut are still counted in the header, still on the timeline, and one tap away.
|
|
||||||
|
|
||||||
**Which view opens is the reader's answer, not ours.** This spec said "calendar (default)" and the
|
**Which view opens is the reader's answer, not ours.** This spec said "calendar (default)" and the
|
||||||
app shipped opening on the list; both were a decision made on the reader's behalf and then forgotten
|
app shipped opening on the list; both were a decision made on the reader's behalf and then forgotten
|
||||||
on every reload. So the first run asks (F8) and the answer is stored in `prefs.view`. The list is
|
on every reload. So the first run asks (F8) and the answer is stored in `prefs.view`. The list is
|
||||||
what the question ships pre-answered with — a reader cannot choose between two layouts they have not
|
what the question ships pre-answered with — a reader cannot choose between two layouts they have not
|
||||||
seen, and it is the view that answers "what expires next" in one look.
|
seen, and it is the view that answers "what expires next" in one look.
|
||||||
|
|
||||||
|
**The list is capped and offers the rest.** Two games already run to twenty-one live events, and a
|
||||||
|
reader who tried exactly that said the list stopped being usable. Each section shows a handful with
|
||||||
|
an explicit "show all N". This truncates the *view* only: the order is untouched, and the rows below
|
||||||
|
the cut are still counted in the header, still on the timeline, and one tap away.
|
||||||
|
|
||||||
**F3 — Mark completed.**
|
**F3 — Mark completed.**
|
||||||
A toggle on every event, in both views. State is written to `localStorage` immediately and
|
A toggle on every event, in both views. State is written to `localStorage` immediately and
|
||||||
optimistically — there is no server round trip and no failure case. Completed events stay visible
|
optimistically — there is no server round trip and no failure case. Completed events stay visible
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ import { URGENCY_COLOR } from "./Meter.tsx";
|
|||||||
|
|
||||||
const DAY_WIDTH = 13; // px per day — dense enough to see a patch cycle at once
|
const DAY_WIDTH = 13; // px per day — dense enough to see a patch cycle at once
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far in from the left edge of the board a pinned label sits.
|
||||||
|
*
|
||||||
|
* Both the lane names and the event names stick here rather than riding off
|
||||||
|
* with their own start dates. A six-week event begins weeks off-screen, and a
|
||||||
|
* bar whose name scrolled away with its start date is a coloured rectangle.
|
||||||
|
*/
|
||||||
|
const PIN = 8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A sliver of time before now, so the "now" rule reads as a line in the view
|
* A sliver of time before now, so the "now" rule reads as a line in the view
|
||||||
* rather than merging with the border.
|
* rather than merging with the border.
|
||||||
@@ -19,12 +28,21 @@ const HALF_DAY_LEAD = 12 * 60 * 60 * 1000;
|
|||||||
*/
|
*/
|
||||||
const PAST_LEAD = 7 * DAY;
|
const PAST_LEAD = 7 * DAY;
|
||||||
|
|
||||||
|
/** Where the now rule sits when the board opens: a little in from the edge. */
|
||||||
|
const OPEN_INSET = 28;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One lane per game, bars spanning start→end, today pinned as a rule.
|
* One lane per game, bars spanning start→end, today pinned as a rule.
|
||||||
*
|
*
|
||||||
* The quiet view. The ending-soon list carries the page's boldness, so this
|
* The quiet view. The ending-soon list carries the page's boldness, so this
|
||||||
* stays flat and legible: no gradients, no rounded chrome, just position and
|
* stays flat and legible: no gradients, no rounded chrome, just position and
|
||||||
* length doing the work.
|
* length doing the work.
|
||||||
|
*
|
||||||
|
* It is a board rather than a stretch of page — its own pane, scrolling in both
|
||||||
|
* directions, with the date axis pinned to the top and every name pinned to the
|
||||||
|
* left. All three used to scroll away together, which is what made a wide
|
||||||
|
* window worse rather than better: more calendar on screen, and nothing left
|
||||||
|
* saying which day, whose game, or which event you were looking at.
|
||||||
*/
|
*/
|
||||||
export function Timeline({
|
export function Timeline({
|
||||||
rows,
|
rows,
|
||||||
@@ -54,18 +72,22 @@ export function Timeline({
|
|||||||
// off-screen; clamping to now made the past unreachable. This does both.
|
// off-screen; clamping to now made the past unreachable. This does both.
|
||||||
const min = Math.min(...starts, now) - PAST_LEAD;
|
const min = Math.min(...starts, now) - PAST_LEAD;
|
||||||
const max = Math.max(...ends, now) + 2 * DAY;
|
const max = Math.max(...ends, now) + 2 * DAY;
|
||||||
// Where "now" sits, so the container can be scrolled there on open.
|
|
||||||
const nowOffset = ((now - HALF_DAY_LEAD - min) / DAY) * DAY_WIDTH;
|
|
||||||
const totalDays = Math.ceil((max - min) / DAY);
|
const totalDays = Math.ceil((max - min) / DAY);
|
||||||
const width = totalDays * DAY_WIDTH;
|
const chartWidth = totalDays * DAY_WIDTH;
|
||||||
|
/** One coordinate space for everything: bars, gridlines and the now rule. */
|
||||||
const x = (ms: number) => ((ms - min) / DAY) * DAY_WIDTH;
|
const x = (ms: number) => ((ms - min) / DAY) * DAY_WIDTH;
|
||||||
|
|
||||||
// Open at today rather than at the far past. Keyed on the rounded offset so
|
// Open at today rather than at the far past, with a little of the past week
|
||||||
// it runs when the range changes, not every second — re-scrolling on each
|
// still on screen — an event that began three days ago is context, not
|
||||||
// tick would fight the reader's own scrolling.
|
// history. Keyed on the rounded offset so it runs when the range changes,
|
||||||
const openAt = Math.round(nowOffset);
|
// not every second: re-scrolling on each tick would fight the reader.
|
||||||
|
const openAt = Math.round(Math.max(0, x(now - HALF_DAY_LEAD) - OPEN_INSET));
|
||||||
|
const jumpToNow = (behavior: ScrollBehavior) =>
|
||||||
|
scroller.current?.scrollTo({ left: openAt, behavior });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scroller.current?.scrollTo({ left: openAt, behavior: "instant" });
|
jumpToNow("instant");
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [openAt]);
|
}, [openAt]);
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@@ -81,91 +103,162 @@ export function Timeline({
|
|||||||
byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]);
|
byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const monthTicks = monthBoundaries(min, max);
|
const months = monthBoundaries(min, max);
|
||||||
|
const weeks = weekBoundaries(min, max);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={scroller} className="scroll-x">
|
<>
|
||||||
<div style={{ width, minWidth: "100%" }} className="relative px-4 pb-8 pt-3">
|
{/* The board's own header. The jump control lives out here rather than
|
||||||
{/* Month rule, so a bar's absolute position means something. */}
|
floating over the chart: pinned inside, it would sit on top of the
|
||||||
<div className="relative mb-3 h-4 border-b border-hairline">
|
calendar and cover the very dates it sends you back to. */}
|
||||||
{monthTicks.map((t) => (
|
<div className="flex items-center justify-between gap-3 border-b border-hairline px-4 py-2.5">
|
||||||
<span
|
<p className="eyebrow">One lane per game</p>
|
||||||
key={t.ms}
|
<button
|
||||||
className="eyebrow absolute -translate-x-px whitespace-nowrap border-l border-hairline pl-1"
|
type="button"
|
||||||
style={{ left: x(t.ms) }}
|
onClick={() => jumpToNow("smooth")}
|
||||||
>
|
className="text-[0.6875rem] font-medium text-faint transition-colors duration-150 hover:text-ink"
|
||||||
{t.label}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="absolute bottom-8 top-8 z-10 w-px bg-critical/70"
|
|
||||||
style={{ left: x(now) + 16 }}
|
|
||||||
>
|
>
|
||||||
<span className="eyebrow absolute -top-4 left-1 text-critical">now</span>
|
Jump to today
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div
|
||||||
{[...byGame.entries()].map(([gameId, events]) => {
|
ref={scroller}
|
||||||
const game = gameMeta(gameId);
|
/*
|
||||||
return (
|
* The pane scrolls, not the page — which is what lets the axis stay
|
||||||
<div key={gameId}>
|
* put. Capped rather than fixed: three lanes take the height they need
|
||||||
<p className="eyebrow mb-1.5" style={{ color: game.hue }}>
|
* and nothing scrolls vertically at all.
|
||||||
{game.short}
|
*/
|
||||||
</p>
|
className="scroll-pane relative max-h-[72vh] overflow-auto overscroll-x-contain"
|
||||||
<div className="relative h-auto space-y-1">
|
>
|
||||||
{events.map(({ event, clock }) => {
|
<div className="relative" style={{ width: chartWidth, minWidth: "100%" }}>
|
||||||
const unknownEnd = clock.endsMs === null;
|
{/* Gridlines first, so everything else paints over them. */}
|
||||||
// Only clipped if it began before the rendered window,
|
<div aria-hidden className="pointer-events-none absolute inset-0">
|
||||||
// which now reaches a week past the oldest running event —
|
{weeks.map((ms) => (
|
||||||
// so in practice bars show their real start and the fade is
|
<span
|
||||||
// reserved for genuinely truncated ones.
|
key={ms}
|
||||||
const clippedStart = clock.startsMs < min;
|
className="absolute bottom-0 top-10 w-px bg-hairline/40"
|
||||||
const left = Math.max(x(clock.startsMs), 0);
|
style={{ left: x(ms) }}
|
||||||
const right = x(clock.endsMs ?? clock.startsMs + 14 * DAY);
|
/>
|
||||||
const done = isDone(event.id);
|
))}
|
||||||
return (
|
{months.slice(1).map((m) => (
|
||||||
<button
|
<span
|
||||||
key={event.id}
|
key={m.ms}
|
||||||
type="button"
|
className="absolute bottom-0 top-10 w-px bg-hairline"
|
||||||
onClick={() => onOpen(event.id)}
|
style={{ left: x(m.ms) }}
|
||||||
title={event.title}
|
/>
|
||||||
className={`relative flex h-6 items-center overflow-hidden rounded-[3px] px-1.5 text-left text-[0.6875rem] font-medium transition-opacity hover:opacity-100 ${
|
))}
|
||||||
done ? "opacity-35" : "opacity-90"
|
</div>
|
||||||
}`}
|
|
||||||
style={{
|
{/* Now: the one rule that has to be findable from anywhere. */}
|
||||||
marginLeft: left,
|
<div
|
||||||
width: Math.max(right - left, 22),
|
aria-hidden
|
||||||
background: `color-mix(in srgb, ${game.hue} 22%, var(--color-surface))`,
|
className="pointer-events-none absolute bottom-0 top-10 z-10 w-px bg-critical/80"
|
||||||
// No start edge to draw when the bar begins before
|
style={{ left: x(now) }}
|
||||||
// the view does.
|
>
|
||||||
borderLeft: clippedStart
|
<span className="eyebrow absolute top-1 -translate-x-1/2 rounded-[3px] bg-critical px-1 py-px text-[0.5625rem] leading-none text-ground">
|
||||||
? undefined
|
now
|
||||||
: `2px solid ${game.hue}`,
|
</span>
|
||||||
// Frayed right = end unannounced; faded left = started
|
</div>
|
||||||
// before now. Both are honest about what is not shown.
|
|
||||||
maskImage: edgeMask(clippedStart, unknownEnd),
|
{/* The axis: months above, week dates below, pinned to the top. */}
|
||||||
color: "var(--color-ink)",
|
<div className="sticky top-0 z-30 h-10 border-b border-hairline bg-ground/95 backdrop-blur">
|
||||||
}}
|
{months.map((m) => (
|
||||||
>
|
<span
|
||||||
<span className="truncate">{event.title}</span>
|
key={m.ms}
|
||||||
<span
|
className="eyebrow absolute top-1 whitespace-nowrap pl-1.5 text-faint"
|
||||||
aria-hidden
|
style={{ left: x(m.ms) }}
|
||||||
className="ml-auto size-1.5 shrink-0 rounded-full"
|
>
|
||||||
style={{ background: URGENCY_COLOR[clock.urgency] }}
|
{m.label}
|
||||||
/>
|
</span>
|
||||||
</button>
|
))}
|
||||||
);
|
{weeks.map((ms) => (
|
||||||
})}
|
<span
|
||||||
|
key={ms}
|
||||||
|
className="tnum absolute bottom-1 whitespace-nowrap pl-1.5 text-[0.625rem] leading-none text-faint"
|
||||||
|
style={{ left: x(ms) }}
|
||||||
|
>
|
||||||
|
{dayLabel(ms)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5 pb-8 pt-4">
|
||||||
|
{[...byGame.entries()].map(([gameId, events]) => {
|
||||||
|
const game = gameMeta(gameId);
|
||||||
|
return (
|
||||||
|
<div key={gameId}>
|
||||||
|
{/* On its own line and pinned to the left edge, so the lane
|
||||||
|
keeps its name at any scroll position without a frozen
|
||||||
|
column standing on top of the calendar. */}
|
||||||
|
<p
|
||||||
|
className="eyebrow sticky left-0 z-20 mb-1.5 w-fit bg-ground pr-2 text-[0.625rem]"
|
||||||
|
style={{ color: game.hue, paddingLeft: PIN }}
|
||||||
|
title={game.name}
|
||||||
|
>
|
||||||
|
{game.short}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="relative space-y-1">
|
||||||
|
{events.map(({ event, clock }) => {
|
||||||
|
const unknownEnd = clock.endsMs === null;
|
||||||
|
// Only clipped if it began before the rendered window,
|
||||||
|
// which reaches a week past the oldest running event — so
|
||||||
|
// in practice bars show their real start and the fade is
|
||||||
|
// reserved for genuinely truncated ones.
|
||||||
|
const clippedStart = clock.startsMs < min;
|
||||||
|
const left = Math.max(x(clock.startsMs), 0);
|
||||||
|
const right = x(clock.endsMs ?? clock.startsMs + 14 * DAY);
|
||||||
|
const done = isDone(event.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={event.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen(event.id)}
|
||||||
|
title={event.title}
|
||||||
|
className={`relative flex h-7 items-center rounded-[4px] px-2 text-left text-[0.6875rem] font-medium transition-opacity hover:opacity-100 ${
|
||||||
|
done ? "opacity-35" : "opacity-90"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
marginLeft: left,
|
||||||
|
width: Math.max(right - left, 26),
|
||||||
|
background: `color-mix(in srgb, ${game.hue} 22%, var(--color-surface))`,
|
||||||
|
// No start edge to draw when the bar begins before
|
||||||
|
// the view does.
|
||||||
|
borderLeft: clippedStart
|
||||||
|
? undefined
|
||||||
|
: `2px solid ${game.hue}`,
|
||||||
|
// Frayed right = end unannounced; faded left =
|
||||||
|
// started before the window. Both are honest about
|
||||||
|
// what is not shown.
|
||||||
|
maskImage: edgeMask(clippedStart, unknownEnd),
|
||||||
|
color: "var(--color-ink)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Sticky clamps to the bar's own box, so a name can
|
||||||
|
never wander outside the event it belongs to. */}
|
||||||
|
<span
|
||||||
|
className="sticky truncate"
|
||||||
|
style={{ left: PIN }}
|
||||||
|
>
|
||||||
|
{event.title}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="ml-auto size-1.5 shrink-0 rounded-full"
|
||||||
|
style={{ background: URGENCY_COLOR[clock.urgency] }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +275,34 @@ function edgeMask(clippedStart: boolean, unknownEnd: boolean): string | undefine
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `18 Aug`, in the reader's own locale, for the week ticks. */
|
||||||
|
function dayLabel(ms: number): string {
|
||||||
|
return new Date(ms).toLocaleDateString(undefined, {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every Monday in range.
|
||||||
|
*
|
||||||
|
* Weeks are the unit these schedules are actually written in — a patch is six
|
||||||
|
* of them — and a tick every seven days is the densest grid that still leaves
|
||||||
|
* room for a date on it.
|
||||||
|
*/
|
||||||
|
function weekBoundaries(min: number, max: number): number[] {
|
||||||
|
const d = new Date(min);
|
||||||
|
d.setUTCHours(0, 0, 0, 0);
|
||||||
|
// 0 is Sunday; step forward to the next Monday.
|
||||||
|
d.setUTCDate(d.getUTCDate() + ((8 - d.getUTCDay()) % 7));
|
||||||
|
const out: number[] = [];
|
||||||
|
while (d.getTime() <= max) {
|
||||||
|
out.push(d.getTime());
|
||||||
|
d.setUTCDate(d.getUTCDate() + 7);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function monthBoundaries(min: number, max: number) {
|
function monthBoundaries(min: number, max: number) {
|
||||||
const short = (ms: number) =>
|
const short = (ms: number) =>
|
||||||
new Date(ms).toLocaleDateString(undefined, { month: "short" });
|
new Date(ms).toLocaleDateString(undefined, { month: "short" });
|
||||||
|
|||||||
Reference in New Issue
Block a user