Skip to content

Commit d8fb237

Browse files
ryanbarlow97claude
andcommitted
Fix framing, following, stepping and accessibility in the movement views
From Astra's review of the rebuilt pages: - A new selection is framed only once its own answer is in, not against the previous trail. - Following an open session stops on a new selection or a recorded end; following a range no longer overwrites From and To while they are edited. - Previous/next observation steps only between recorded rows; the point made up where a clipped path meets the range's edge is never a dot or an observation label. - Only selected is kept in the URL, so copied links show the same players. - The timeline shows keyboard focus and reads out its moment; controls and checkboxes keep 44 px targets. - Local times that repeat when the clocks change are caught for any size of change, and day ticks stay on local midnight across one. - On phones the scale bar sits clear of the legend and labels near the right edge turn to the left. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 0d82048 commit d8fb237

8 files changed

Lines changed: 182 additions & 54 deletions

File tree

‎frontend/app/components/admin/EveryoneMovement.test.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,8 @@ it("compares everyone at the inspected moment and carries the view to a player",
8888

8989
fireEvent.click(screen.getByLabelText("Select Bob"));
9090
expect(nav.state.params.get("players")).toBe(B);
91+
// "Only selected" lives in the URL, so a copied link shows the same players.
92+
fireEvent.click(await screen.findByLabelText(/Only selected/));
93+
expect(nav.state.params.get("only")).toBe("1");
94+
await screen.findByText(/1 of 1 players observed/);
9195
});

‎frontend/app/components/admin/EveryoneMovement.tsx‎

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ type Load =
4444
| { kind: "loading"; previous: Movement | null }
4545
| { kind: GateKind }
4646
| { kind: "failed"; message: string; previous: Movement | null }
47-
| { kind: "ready"; data: Movement };
47+
| { kind: "ready"; data: Movement; key: string };
4848

4949
function intParam(value: string | null): number | null {
5050
return value !== null && /^-?\d+$/.test(value) ? Number(value) : null;
@@ -86,7 +86,7 @@ export default function EveryoneMovement() {
8686
const at = intParam(search.get("at"));
8787
const pin = pinParam(search.get("pin"));
8888
const chosen = useMemo(() => new Set((search.get("players") ?? "").split(",").filter(Boolean)), [search]);
89-
const [onlyChosen, setOnlyChosen] = useState(false);
89+
const onlyChosen = search.get("only") === "1";
9090
const [query, setQuery] = useState("");
9191
const [highlight, setHighlight] = useState<string | null>(null);
9292

@@ -115,7 +115,7 @@ export default function EveryoneMovement() {
115115
previous: prev.kind === "ready" ? prev.data : prev.kind === "loading" || prev.kind === "failed" ? prev.previous : null,
116116
}));
117117
getEveryoneMovement(range.from, range.to)
118-
.then((data) => live && setLoad({ kind: "ready", data }))
118+
.then((data) => live && setLoad({ kind: "ready", data, key: viewKey }))
119119
.catch((err) => {
120120
if (!live) return;
121121
const gate = gateKind(err);
@@ -151,8 +151,8 @@ export default function EveryoneMovement() {
151151
const since = data?.since ?? range.from;
152152
const until = data?.until ?? range.to;
153153
const times = useMemo(
154-
() => observationTimes(drawn.flatMap((t) => clipStretches(t.stretches, since, until))),
155-
[drawn, since, until]
154+
() => observationTimes(drawn.flatMap((t) => t.stretches), data?.complete_from ?? since, until),
155+
[drawn, data, since, until]
156156
);
157157

158158
if (load.kind === "forbidden") {
@@ -230,18 +230,18 @@ export default function EveryoneMovement() {
230230
onChange={(e) => setQuery(e.target.value)}
231231
/>
232232
<div className="flex flex-wrap items-center gap-3 text-sm text-[var(--tfmc-cream)]">
233-
<label className="flex items-center gap-2">
233+
<label className="flex min-h-11 items-center gap-2">
234234
<input
235235
type="checkbox"
236236
className="h-4 w-4 accent-[var(--tfmc-accent)]"
237237
checked={onlyChosen}
238238
disabled={!chosen.size}
239-
onChange={(e) => setOnlyChosen(e.target.checked)}
239+
onChange={(e) => update({ only: e.target.checked ? "1" : null }, true)}
240240
/>
241241
Only selected ({chosen.size})
242242
</label>
243243
{chosen.size ? (
244-
<button type="button" className="text-[var(--tfmc-stone)] underline" onClick={() => update({ players: null }, true)}>
244+
<button type="button" className="text-[var(--tfmc-stone)] underline" onClick={() => update({ players: null, only: null }, true)}>
245245
Clear selection
246246
</button>
247247
) : null}
@@ -254,13 +254,16 @@ export default function EveryoneMovement() {
254254
onMouseEnter={() => setHighlight(trail.key)}
255255
onMouseLeave={() => setHighlight(null)}
256256
>
257-
<input
258-
type="checkbox"
259-
aria-label={`Select ${trail.label}`}
260-
className="h-4 w-4 shrink-0 accent-[var(--tfmc-accent)]"
261-
checked={chosen.has(trail.key)}
262-
onChange={() => toggle(trail.key)}
263-
/>
257+
{/* A 44 px target around a small box. */}
258+
<label className="-my-1 flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center">
259+
<input
260+
type="checkbox"
261+
aria-label={`Select ${trail.label}`}
262+
className="h-4 w-4 accent-[var(--tfmc-accent)]"
263+
checked={chosen.has(trail.key)}
264+
onChange={() => toggle(trail.key)}
265+
/>
266+
</label>
264267
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: trail.colour }} />
265268
<Link
266269
href={playerHref(trail.key)}
@@ -308,7 +311,7 @@ export default function EveryoneMovement() {
308311
hold={hold}
309312
highlight={highlight}
310313
pin={pin}
311-
fitKey={load.kind === "ready" ? viewKey : null}
314+
fitKey={load.kind === "ready" && load.key === viewKey ? viewKey : null}
312315
onInspect={(time) => update({ at: String(time) }, true)}
313316
className="h-[60vh] min-h-[22rem] rounded-sm lg:h-[calc(100dvh-24rem)]"
314317
/>

‎frontend/app/components/admin/MovementControls.tsx‎

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,12 @@ import {
1818
zoneLabel,
1919
} from "../../../lib/admin/movement";
2020

21-
export const chipClass =
22-
"min-h-11 rounded-sm border px-3 py-1 text-sm transition-colors disabled:opacity-40 sm:min-h-0";
21+
export const chipClass = "min-h-11 rounded-sm border px-3 py-1 text-sm transition-colors disabled:opacity-40";
2322
export const chipOn = "border-[var(--tfmc-accent)] text-[var(--tfmc-cream)]";
2423
export const chipOff =
2524
"border-[color-mix(in_srgb,var(--tfmc-cream)_20%,transparent)] text-[var(--tfmc-stone)] hover:text-[var(--tfmc-cream)]";
2625
export const inputClass =
27-
"min-h-11 w-full rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_20%,transparent)] bg-transparent px-2 py-1 text-sm text-[var(--tfmc-cream)] [color-scheme:dark] sm:min-h-0";
26+
"min-h-11 w-full rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_20%,transparent)] bg-transparent px-2 py-1 text-sm text-[var(--tfmc-cream)] [color-scheme:dark]";
2827
export const mutedClass = "text-sm text-[var(--tfmc-mist)]";
2928
const errorClass = "text-sm text-[#e8a0a0]";
3029

@@ -102,10 +101,14 @@ export function RangeForm({
102101
const [toText, setToText] = useState(toLocalInput(value.to));
103102
const [seen, setSeen] = useState(value);
104103
const [error, setError] = useState<string | null>(null);
104+
// Typed but not applied: following the clock must not overwrite it.
105+
const [dirty, setDirty] = useState(false);
105106
if (seen.from !== value.from || seen.to !== value.to) {
106107
setSeen(value);
107-
setFromText(toLocalInput(value.from));
108-
setToText(toLocalInput(value.to));
108+
if (!dirty) {
109+
setFromText(toLocalInput(value.from));
110+
setToText(toLocalInput(value.to));
111+
}
109112
}
110113

111114
const apply = (follow: boolean) => {
@@ -116,6 +119,7 @@ export function RangeForm({
116119
if (from.time! >= to.time!) return setError("From must be before To.");
117120
if (to.time! - from.time! > longest) return setError(`Choose a range of up to ${formatDuration(longest).toLowerCase()}.`);
118121
setError(null);
122+
setDirty(false);
119123
onApply({ from: from.time!, to: to.time!, follow });
120124
};
121125

@@ -137,6 +141,9 @@ export function RangeForm({
137141
if (!preset) return;
138142
const [from, to] = preset.range(Math.floor(Date.now() / 1000));
139143
setError(null);
144+
setDirty(false);
145+
setFromText(toLocalInput(from));
146+
setToText(toLocalInput(to));
140147
onApply({ from, to, follow: false });
141148
}}
142149
>
@@ -149,11 +156,11 @@ export function RangeForm({
149156
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
150157
<label className="flex flex-col gap-1 text-sm text-[var(--tfmc-mist)]">
151158
From
152-
<input type="datetime-local" className={inputClass} value={fromText} onChange={(e) => setFromText(e.target.value)} />
159+
<input type="datetime-local" className={inputClass} value={fromText} onChange={(e) => { setFromText(e.target.value); setDirty(true); }} />
153160
</label>
154161
<label className="flex flex-col gap-1 text-sm text-[var(--tfmc-mist)]">
155162
To
156-
<input type="datetime-local" className={inputClass} value={toText} onChange={(e) => setToText(e.target.value)} />
163+
<input type="datetime-local" className={inputClass} value={toText} onChange={(e) => { setToText(e.target.value); setDirty(true); }} />
157164
</label>
158165
</div>
159166
<div className="flex flex-wrap gap-2">
@@ -163,19 +170,25 @@ export function RangeForm({
163170
<button
164171
type="button"
165172
className={`${chipClass} ${chipOff}`}
166-
onClick={() => setToText(toLocalInput(Math.floor(Date.now() / 1000)))}
173+
onClick={() => {
174+
setToText(toLocalInput(Math.floor(Date.now() / 1000)));
175+
setDirty(true);
176+
}}
167177
>
168178
To now
169179
</button>
170180
</div>
171-
<label className="flex items-center gap-2 text-sm text-[var(--tfmc-cream)]">
181+
<label className="flex min-h-11 items-center gap-2 text-sm text-[var(--tfmc-cream)]">
172182
<input
173183
type="checkbox"
174184
className="h-4 w-4 accent-[var(--tfmc-accent)]"
175185
checked={value.follow}
176186
onChange={(event) => {
177187
if (event.target.checked) {
178188
const now = Math.floor(Date.now() / 1000);
189+
setDirty(false);
190+
setFromText(toLocalInput(now - (value.to - value.from)));
191+
setToText(toLocalInput(now));
179192
onApply({ from: now - (value.to - value.from), to: now, follow: true });
180193
} else {
181194
onApply({ ...value, follow: false });
@@ -270,17 +283,20 @@ export function Timeline({
270283
{ticks.map((t) => (
271284
<div key={t} className="absolute bottom-0 h-2 border-l border-[color-mix(in_srgb,var(--tfmc-cream)_35%,transparent)]" style={{ left: pct(t) }} />
272285
))}
273-
<div className="pointer-events-none absolute inset-y-[-3px] w-0.5 bg-white shadow" style={{ left: pct(cursor) }} />
274286
<input
275287
type="range"
276288
aria-label="Inspected moment"
289+
aria-valuetext={formatMoment(cursor, true)}
277290
min={since}
278291
max={until}
279-
step={1}
292+
step={60}
280293
value={Math.min(until, Math.max(since, cursor))}
281294
onChange={(event) => onCursor(Number(event.target.value))}
282-
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
295+
className="peer absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
283296
/>
297+
{/* The input is invisible, so its keyboard focus is drawn here. */}
298+
<div className="pointer-events-none absolute inset-0 rounded-sm peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--tfmc-accent)]" />
299+
<div className="pointer-events-none absolute inset-y-[-3px] w-0.5 bg-white shadow" style={{ left: pct(cursor) }} />
284300
</div>
285301
<div className="relative h-4 text-[11px] text-[var(--tfmc-mist)]">
286302
{ticks.map((t) => (

‎frontend/app/components/admin/MovementMap.tsx‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,9 @@ function endpointLabels(trail: MovementTrail, mapWorld: string): { sample: Sampl
144144
const first = shown[0].samples[0];
145145
const lastStretch = shown[shown.length - 1].samples;
146146
const last = lastStretch[lastStretch.length - 1];
147-
const start = `${first.action === ACTION_LOGIN ? "Logged in" : "First observation shown"} ${formatClock(first.time)}`;
147+
const start = first.estimated
148+
? `Shown from ${formatClock(first.time)} (estimated position)`
149+
: `${first.action === ACTION_LOGIN ? "Logged in" : "First observation shown"} ${formatClock(first.time)}`;
148150
const end = `${last.action === ACTION_LOGOUT ? "Logged out" : "Last observation shown"} ${formatClock(last.time)}`;
149151
if (first === last) return [{ sample: first, text: start }];
150152
return [
@@ -251,6 +253,12 @@ export default function MovementMap({
251253
const single = trails.length === 1 ? markers[0]?.at : null;
252254
const scaleBlocks = displayScale > 0 ? niceLength(100 / displayScale) : 0;
253255
const halo = { stroke: "#10160f", strokeWidth: 3 * unit, paintOrder: "stroke" as const };
256+
/** Text beside a map point, flipped to its left when it would run off the right of the view. */
257+
const beside = (x: number, gap: number) => {
258+
const screenX = (x + 0.5) * displayScale + viewport.translateX;
259+
const flip = screenX > viewport.viewportSize.w - 220;
260+
return { x: x + 0.5 + (flip ? -gap : gap) * unit, textAnchor: flip ? ("end" as const) : ("start" as const) };
261+
};
254262

255263
const dotTitle = (trail: MovementTrail, s: Sample) =>
256264
`${trail.label} — ${formatClock(s.time, true)}${
@@ -349,7 +357,8 @@ export default function MovementMap({
349357
stretch.world === mapWorld
350358
? stretch.samples.map((s, i) => {
351359
const edge = s.action === ACTION_LOGIN || s.action === ACTION_LOGOUT;
352-
if (!edge && !dots) return null;
360+
// A point made up at the range's edge is drawn through, never as a recorded dot.
361+
if (s.estimated || (!edge && !dots)) return null;
353362
const fill =
354363
s.action === ACTION_LOGIN
355364
? "#7fd18b"
@@ -410,7 +419,7 @@ export default function MovementMap({
410419
return labels.map((label, i) => (
411420
<text
412421
key={`${trail.key}:label:${i}`}
413-
x={label.sample.x + 0.5 + 9 * unit}
422+
{...beside(label.sample.x, 9)}
414423
y={label.sample.z + 0.5 - 7 * unit}
415424
fontSize={12 * unit}
416425
fontWeight={600}
@@ -433,7 +442,7 @@ export default function MovementMap({
433442
strokeWidth={1.5 * unit}
434443
/>
435444
<text
436-
x={pin.x + 0.5 + 12 * unit}
445+
{...beside(pin.x, 12)}
437446
y={pin.z + 0.5 + 16 * unit}
438447
fontSize={12 * unit}
439448
fontWeight={600}
@@ -462,7 +471,7 @@ export default function MovementMap({
462471
/>
463472
{trails.length > 1 ? (
464473
<text
465-
x={at.x + 0.5 + 12 * unit}
474+
{...beside(at.x, 12)}
466475
y={at.z + 0.5 + 4 * unit}
467476
fontSize={13 * unit}
468477
fontWeight={600}
@@ -485,13 +494,13 @@ export default function MovementMap({
485494
title={button.title}
486495
aria-label={button.title}
487496
onClick={button.run}
488-
className="h-11 w-11 rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_25%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-forest-deep)_85%,transparent)] text-lg text-[var(--tfmc-cream)] hover:border-[var(--tfmc-accent)] sm:h-9 sm:w-9 sm:text-base"
497+
className="h-11 w-11 rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_25%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-forest-deep)_85%,transparent)] text-lg text-[var(--tfmc-cream)] hover:border-[var(--tfmc-accent)]"
489498
>
490499
{button.label}
491500
</button>
492501
))}
493502
</div>
494-
<div className="pointer-events-none absolute bottom-2 left-2 z-20 flex flex-col gap-1 rounded-sm bg-[color-mix(in_srgb,var(--tfmc-forest-deep)_82%,transparent)] px-2.5 py-1.5 text-[11px] text-[var(--tfmc-cream)]">
503+
<div className="pointer-events-none absolute bottom-2 left-2 right-2 z-20 flex w-fit max-w-[calc(100%-1rem)] flex-col gap-1 rounded-sm bg-[color-mix(in_srgb,var(--tfmc-forest-deep)_82%,transparent)] px-2.5 py-1.5 text-[11px] text-[var(--tfmc-cream)]">
495504
{trails.length === 1 && !trails[0].colour ? (
496505
<span className="flex items-center gap-1.5">
497506
older
@@ -502,18 +511,18 @@ export default function MovementMap({
502511
newer
503512
</span>
504513
) : null}
505-
<span className="flex items-center gap-1.5">
514+
<span className="flex flex-wrap items-center gap-1.5">
506515
<span className="inline-block h-2.5 w-2.5 rounded-full border-2 border-white bg-[#f4c96b]" /> recorded
507516
<span className="ml-2 inline-block h-2.5 w-2.5 rounded-full border-2 border-[#f4c96b]" /> estimate or last seen
508517
</span>
509-
<span className="flex items-center gap-1.5">
518+
<span className="flex flex-wrap items-center gap-1.5">
510519
<span className="inline-block w-5 border-t-2 border-dashed border-[#e8e4d9]" /> unobserved transition
511520
<span className="ml-2 inline-block h-2 w-2 rounded-full bg-[#7fd18b]" /> logged in
512521
<span className="inline-block h-2 w-2 rounded-full bg-[#e8796f]" /> logged out
513522
</span>
514523
</div>
515524
{scaleBlocks ? (
516-
<div className="pointer-events-none absolute bottom-2 right-2 z-20 rounded-sm bg-[color-mix(in_srgb,var(--tfmc-forest-deep)_82%,transparent)] px-2 py-1 text-[11px] text-[var(--tfmc-cream)]">
525+
<div className="pointer-events-none absolute left-2 top-2 z-20 rounded-sm bg-[color-mix(in_srgb,var(--tfmc-forest-deep)_82%,transparent)] px-2 py-1 text-[11px] text-[var(--tfmc-cream)]">
517526
<div
518527
className="border-x-2 border-b-2 border-[var(--tfmc-cream)]"
519528
style={{ width: scaleBlocks * displayScale, height: 5 }}

‎frontend/app/components/admin/PlayerMovementPage.test.tsx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,21 @@ it("tells non-admins the page is not for them", async () => {
144144
render(<PlayerMovementPage uuid={UUID} />);
145145
await screen.findByText("Movement is for admins and the owner only.");
146146
});
147+
148+
it("frames a new session only once its own answer is in", async () => {
149+
render(<PlayerMovementPage uuid={UUID} />);
150+
await screen.findByText(/recorded at 30, 64, 0/);
151+
expect(screen.getByTestId("map").dataset.fit).toBe("session:s1");
152+
let answer: (value: unknown) => void = () => undefined;
153+
vi.mocked(getSessionMovement).mockReturnValueOnce(new Promise((resolve) => (answer = resolve)) as never);
154+
act(() => nav.go("/x?session=s2"));
155+
// Still showing s1's answer: the map must not take s2's key against s1's trail.
156+
expect(screen.getByTestId("map").dataset.fit).toBe("");
157+
await act(async () =>
158+
answer({
159+
session: { ...SESSION, id: "s2" }, since: T, until: T + 600, worlds: ["TFMC_Map"],
160+
points: [[T, 0, 500, 64, 0, 1]], complete_from: T, pings_since: null, as_of: T, coreprotect: status,
161+
})
162+
);
163+
await waitFor(() => expect(screen.getByTestId("map").dataset.fit).toBe("session:s2"));
164+
});

0 commit comments

Comments
 (0)