Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions frontend/app/components/admin/AdminPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,7 @@ export default function AdminPanel() {
return (
<>
<p className="mt-2 text-sm text-[var(--tfmc-mist)]">
Signed in as {me.discord_username ? `@${me.discord_username}` : "staff"} · {roleLabel(me.role)}. Every
change here is recorded with your reason.
Signed in as {me.discord_username ? `@${me.discord_username}` : "staff"} · {roleLabel(me.role)}. Changes and reasons are recorded.
</p>

{/* The roster beside the lookup on wide screens; stacked below that. */}
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/components/admin/EveryoneMovement.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ afterEach(() => {

it("compares everyone at the inspected moment and carries the view to a player", async () => {
render(<EveryoneMovement />);
await screen.findByText(/2 of 2 players observed or estimated at this moment/);
await screen.findByText(/2 of 2 players seen/);
expect(getEveryoneMovement).toHaveBeenCalledWith(T - 3600, T);
expect(screen.getByText("11, 21")).toBeTruthy();
// Bob's last ping was 90 s before the moment, in the End.
Expand All @@ -91,5 +91,5 @@ it("compares everyone at the inspected moment and carries the view to a player",
// "Only selected" lives in the URL, so a copied link shows the same players.
fireEvent.click(await screen.findByLabelText(/Only selected/));
expect(nav.state.params.get("only")).toBe("1");
await screen.findByText(/1 of 1 players observed/);
await screen.findByText(/1 of 1 players seen/);
});
21 changes: 8 additions & 13 deletions frontend/app/components/admin/EveryoneMovement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,11 @@ function whereText(found: Inspection, moment: number, mapWorld: string): string
case "estimated":
return `≈ ${place(found.x, found.z, found.world)}`;
case "unobserved":
return "unobserved transition";
return "unknown";
case "stale":
return `${place(found.before.x, found.before.z, found.world)} · ${formatDuration(moment - found.before.time).toLowerCase()} before`;
default:
return "not observed";
return "not seen";
}
}

Expand Down Expand Up @@ -255,11 +255,7 @@ function EveryoneRange({ viewSwitch }: { viewSwitch: ReactNode }) {
panel={
<>
{viewSwitch}
<p className={mutedClass}>
{data?.coreprotect.server_label ? `${data.coreprotect.server_label} · ` : ""}
Everyone online over a range of up to 24 hours. Positions are recorded once a minute; lines between them are
estimates. Each view is logged.
</p>
<p className={mutedClass}>Lines between positions are estimates. Views are logged.</p>
{data ? <CopyButton text={shareUrl()} label="Copy link to this view" /> : null}
{notice ? <p className="text-sm text-[#e8c48a]">{notice}</p> : null}
{load.kind === "failed" ? (
Expand Down Expand Up @@ -316,7 +312,7 @@ function EveryoneRange({ viewSwitch }: { viewSwitch: ReactNode }) {
since={since}
until={until}
unknownUntil={unknownUntil}
unknownLabel={completeFrom > since ? "earlier observations omitted" : "before available position observations"}
unknownLabel={completeFrom > since ? "too many to show" : "login and logout locations only"}
bands={bands}
cursor={moment}
onCursor={setMoment}
Expand All @@ -327,17 +323,16 @@ function EveryoneRange({ viewSwitch }: { viewSwitch: ReactNode }) {
<>
{data ? (
<InspectBar cursor={moment} since={since} until={until} times={times} onCursor={setMoment}>
Inspecting {formatClock(moment, true)} · {seenNow} of {drawn.length} players observed or estimated at this
moment. Positions in the list are as of this moment.
{formatClock(moment, true)} · {seenNow} of {drawn.length} players seen or estimated
</InspectBar>
) : null}
{data && completeFrom > since ? (
<p className={mutedClass}>
Earlier observations omitted: too many to show, so the range starts at {formatMoment(completeFrom)}.
Too many positions to show; the range starts at {formatMoment(completeFrom)}.
</p>
) : null}
{data && data.pings_since !== null && data.pings_since > since ? (
<p className={mutedClass}>Before {formatMoment(data.pings_since)} there are no available position observations.</p>
<p className={mutedClass}>Before {formatMoment(data.pings_since)}, only login and logout locations.</p>
) : null}
<div className="flex flex-col gap-2 border-t border-[color-mix(in_srgb,var(--tfmc-cream)_12%,transparent)] pt-3">
<input
Expand Down Expand Up @@ -397,7 +392,7 @@ function EveryoneRange({ viewSwitch }: { viewSwitch: ReactNode }) {
</span>
</li>
))}
{data && !all.length ? <li className={mutedClass}>Nobody was observed in this range.</li> : null}
{data && !all.length ? <li className={mutedClass}>Nobody was seen in this range.</li> : null}
</ul>
</div>
</>
Expand Down
8 changes: 4 additions & 4 deletions frontend/app/components/admin/EveryoneNow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ afterEach(() => {

it("opens on where everyone online is now, and refreshes", async () => {
render(<EveryoneMovement />);
await screen.findByText(/2 players online · updated 14:00:00/);
await screen.findByText(/2 players seen recently · updated 14:00:00/);
expect(getLatestMovement).toHaveBeenCalledWith(T - 150);
// Carol logged out: not listed. Bob is in the Nether: listed, not on the map's world.
expect(screen.getByText("11, 21 · 40 s ago")).toBeTruthy();
Expand All @@ -109,7 +109,7 @@ it("opens on where everyone online is now, and refreshes", async () => {

it("keeps the last positions when a refresh fails", async () => {
render(<EveryoneMovement />);
await screen.findByText(/2 players online/);
await screen.findByText(/2 players seen recently/);
vi.mocked(getLatestMovement).mockRejectedValueOnce(new Error("boom"));
await act(async () => {
vi.advanceTimersByTime(NOW_REFRESH_MS);
Expand All @@ -120,7 +120,7 @@ it("keeps the last positions when a refresh fails", async () => {

it("switches between now and a time range", async () => {
render(<EveryoneMovement />);
await screen.findByText(/2 players online/);
await screen.findByText(/2 players seen recently/);
fireEvent.click(screen.getByRole("button", { name: "Time range" }));
expect(nav.state.params.get("view")).toBe("range");
expect(screen.getByRole("button", { name: "Time range" }).getAttribute("aria-pressed")).toBe("true");
Expand All @@ -130,7 +130,7 @@ it("switches between now and a time range", async () => {

it("ignores an answer that arrives after a newer one", async () => {
render(<EveryoneMovement />);
await screen.findByText(/2 players online/);
await screen.findByText(/2 players seen recently/);
let answerSlow: (value: Awaited<ReturnType<typeof getLatestMovement>>) => void = () => {};
const first = await vi.mocked(getLatestMovement).mock.results[0].value;
vi.mocked(getLatestMovement)
Expand Down
11 changes: 3 additions & 8 deletions frontend/app/components/admin/EveryoneNow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,7 @@ export default function EveryoneNow({ viewSwitch = null }: { viewSwitch?: ReactN
panel={
<>
{viewSwitch}
<p className={mutedClass}>
{data?.coreprotect.server_label ? `${data.coreprotect.server_label} · ` : ""}
Where everyone online is now: each player&rsquo;s latest recorded position. Positions are recorded once a
minute, so one can be up to a minute old. Refreshes every {NOW_REFRESH_MS / 1000} seconds; each refresh is
logged.
</p>
<p className={mutedClass}>Positions update about once a minute. Views are logged.</p>
{notice ? <p className="text-sm text-[#e8c48a]">{notice}</p> : null}
{load.kind === "ready" && load.error ? (
<div className="flex flex-wrap items-center gap-3">
Expand Down Expand Up @@ -199,7 +194,7 @@ export default function EveryoneNow({ viewSwitch = null }: { viewSwitch?: ReactN
<>
<p className="text-sm text-[var(--tfmc-cream)]" aria-live="polite">
{data
? `${positions.length} ${positions.length === 1 ? "player" : "players"} online · updated ${formatClock(
? `${positions.length} ${positions.length === 1 ? "player" : "players"} seen recently · updated ${formatClock(
data.as_of ?? data.until,
true
)}`
Expand Down Expand Up @@ -236,7 +231,7 @@ export default function EveryoneNow({ viewSwitch = null }: { viewSwitch?: ReactN
</span>
</li>
))}
{data && !positions.length ? <li className={mutedClass}>Nobody is online.</li> : null}
{data && !positions.length ? <li className={mutedClass}>Nobody seen recently.</li> : null}
</ul>
</>
}
Expand Down
18 changes: 8 additions & 10 deletions frontend/app/components/admin/MovementControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { useAccessibleMaps } from "../../hooks/useAccessibleMaps";
import { liveMapIdFrom } from "../../lib/map/chronicleDayRoute";
import { formatDuration } from "../../../lib/admin/time";
import {
describeSpan,
formatClock,
formatDay,
formatMoment,
Expand Down Expand Up @@ -254,16 +253,15 @@ export function RangeForm({
}
}}
/>
Keep up to date (moves the range with the clock)
Move range with the clock
</label>
{error ? (
<p className={errorClass} role="alert">
{error}
</p>
) : null}
<p className={mutedClass}>
Showing <span className="text-[var(--tfmc-cream)]">{describeSpan(value.from, value.to)}</span> ·{" "}
{formatDuration(value.to - value.from).toLowerCase()} · times in {zoneLabel(value.from, value.to)}
{formatDuration(value.to - value.from)} · times in {zoneLabel(value.from, value.to)}
{value.follow && asOf ? ` · updated ${formatClock(asOf)}` : ""}
</p>
</form>
Expand Down Expand Up @@ -366,11 +364,11 @@ export function Timeline({
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-[var(--tfmc-mist)]">
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-4 rounded-sm bg-[var(--tfmc-accent)]" /> observed
<span className="inline-block h-2 w-4 rounded-sm bg-[var(--tfmc-accent)]" /> seen
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-4 rounded-sm bg-[color-mix(in_srgb,var(--tfmc-cream)_6%,transparent)]" /> not
observed (offline, or not recorded)
seen (not proof of offline)
</span>
{hatchEnd !== null ? (
<span className="flex items-center gap-1.5">
Expand Down Expand Up @@ -420,7 +418,7 @@ export function InspectBar({
<div className="flex flex-col gap-2">
{/* The box on its own row, the steps together below it: nothing beside a date box to run into. */}
<label className="flex w-full max-w-72 flex-col gap-1 text-sm text-[var(--tfmc-mist)]">
Inspect time
Time
<input
type="datetime-local"
className={dateInputClass}
Expand All @@ -442,15 +440,15 @@ export function InspectBar({
disabled={previous === null}
onClick={() => previous !== null && onCursor(previous)}
>
‹ Previous observation
‹ Previous
</button>
<button
type="button"
className={`${chipClass} ${chipOff}`}
disabled={next === null}
onClick={() => next !== null && onCursor(next)}
>
Next observation ›
Next ›
</button>
</div>
{error ? (
Expand Down Expand Up @@ -495,7 +493,7 @@ function PinForm({ pin, onChange }: { pin: MapPin | null; onChange: (pin: MapPin
if (valid) onChange({ x: Number(x.trim()), z: Number(z.trim()) });
}}
>
<span className="text-sm text-[var(--tfmc-mist)]">Pin a place on the overworld map (for a report)</span>
<span className="text-sm text-[var(--tfmc-mist)]">Pin a place in the overworld</span>
<div className="flex gap-2">
<input aria-label="Pin x" placeholder="x" inputMode="numeric" className={inputClass} value={x} onChange={(e) => setX(e.target.value)} />
<input aria-label="Pin z" placeholder="z" inputMode="numeric" className={inputClass} value={z} onChange={(e) => setZ(e.target.value)} />
Expand Down
14 changes: 7 additions & 7 deletions frontend/app/components/admin/MovementMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,9 @@ function endpointLabels(trail: MovementTrail, mapWorld: string): { sample: Sampl
const lastStretch = shown[shown.length - 1].samples;
const last = lastStretch[lastStretch.length - 1];
const start = first.estimated
? `Shown from ${formatClock(first.time)} (estimated position)`
: `${first.action === ACTION_LOGIN ? "Logged in" : "First observation shown"} ${formatClock(first.time)}`;
const end = `${last.action === ACTION_LOGOUT ? "Logged out" : "Last observation shown"} ${formatClock(last.time)}`;
? `≈ First shown ${formatClock(first.time)}`
: `${first.action === ACTION_LOGIN ? "Logged in" : "First shown"} ${formatClock(first.time)}`;
const end = `${last.action === ACTION_LOGOUT ? "Logged out" : "Last shown"} ${formatClock(last.time)}`;
if (first === last) return [{ sample: first, text: start }];
return [
{ sample: first, text: start },
Expand Down Expand Up @@ -677,8 +677,8 @@ export default function MovementMap({
) : null}
{latest ? (
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-full border-2 border-white bg-[#f4c96b]" /> last recorded
position (once a minute)
<span className="inline-block h-2.5 w-2.5 rounded-full border-2 border-white bg-[#f4c96b]" /> last
position
</span>
) : (
<>
Expand All @@ -694,10 +694,10 @@ export default function MovementMap({
) : null}
<span className="flex flex-wrap items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-full border-2 border-white bg-[#f4c96b]" /> recorded
<span className="ml-2 inline-block h-2.5 w-2.5 rounded-full border-2 border-[#f4c96b]" /> estimate or last seen
<span className="ml-2 inline-block h-2.5 w-2.5 rounded-full border-2 border-[#f4c96b]" /> estimated or last seen
</span>
<span className="flex flex-wrap items-center gap-1.5">
<span className="inline-block w-5 border-t-2 border-dashed border-[#e8e4d9]" /> unobserved transition
<span className="inline-block w-5 border-t-2 border-dashed border-[#e8e4d9]" /> route unknown
<span className="ml-2 inline-block h-2 w-2 rounded-full bg-[#7fd18b]" /> logged in
<span className="inline-block h-2 w-2 rounded-full bg-[#e8796f]" /> logged out
</span>
Expand Down
8 changes: 3 additions & 5 deletions frontend/app/components/admin/PlayerActivity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const KIND_LABELS: Record<string, string> = {
chat: "Chat",
};

const SOURCE_LABELS: Record<string, string> = { mmoitems: "MMOItems", mythicmobs: "MythicMobs" };
const SOURCE_LABELS: Record<string, string> = { mmoitems: "Custom item", mythicmobs: "Custom mob" };

// Each channel in the colour its in-game tag uses (RPCharacters' chat.yml).
const CHANNEL_COLOURS: Record<string, string> = {
Expand All @@ -49,7 +49,7 @@ function ChannelTag({ channel, inferred = false }: { channel: string; inferred?:
const colour = CHANNEL_COLOURS[channel] ?? "var(--tfmc-mist)";
return (
<span
title={inferred ? "Their channel at the time, from their last /channel switch since logging in" : undefined}
title={inferred ? "Inferred from their last /channel switch" : undefined}
className="mr-1.5 inline-block rounded-full border px-1.5 align-[1px] text-[10px] font-semibold uppercase tracking-wide"
style={{ color: colour, borderColor: `color-mix(in srgb, ${colour} 45%, transparent)` }}
>
Expand Down Expand Up @@ -322,9 +322,7 @@ export default function PlayerActivity({ uuid }: { uuid: string }) {
<section className={panelClass} aria-label="Recent activity">
<h3 className={headingClass}>Recent activity</h3>
<p className="mt-1 text-xs text-[var(--tfmc-stone)]">
{showsMessages
? "From CoreProtect, including chat and whole commands. Your views of these are logged. Sign text is not shown."
: "From CoreProtect. Chat, command arguments and sign text are not shown."}
{showsMessages ? "Includes chat and full commands; sign text is hidden. Views are logged." : "Chat, command details and sign text are hidden."}
</p>
{/* One row that scrolls sideways on a phone; wraps where there is room. */}
<div
Expand Down
18 changes: 9 additions & 9 deletions frontend/app/components/admin/PlayerMovementPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ afterEach(() => {

it("opens the newest session and inspects where it ended", async () => {
render(<PlayerMovementPage uuid={UUID} />);
await screen.findByText(/Inspecting 14:10:00 · recorded at 30, 64, 0/);
await screen.findByText(/14:10:00 · 30, 64, 0/);
expect(nav.state.params.get("session")).toBe("s1");
expect(getSessionMovement).toHaveBeenCalledWith(UUID, "s1");
expect(screen.getAllByText("Logged out 14:10").length).toBeGreaterThan(0);
Expand All @@ -109,14 +109,14 @@ it("opens the newest session and inspects where it ended", async () => {

it("steps between observations and explains estimates", async () => {
render(<PlayerMovementPage uuid={UUID} />);
await screen.findByText(/recorded at 30, 64, 0/);
fireEvent.click(screen.getByRole("button", { name: "‹ Previous observation" }));
await screen.findByText(/Inspecting 14:02:00 · recorded at 20, 64, 0/);
await screen.findByText(/14:10:00 · 30, 64, 0/);
fireEvent.click(screen.getByRole("button", { name: "‹ Previous" }));
await screen.findByText(/14:02:00 · 20, 64, 0/);
act(() => nav.go(`/x?session=s1&at=${T + 90}`));
await screen.findByText(/estimated between observations at 14:01:00 and 14:02:00: near 15, 0/);
await screen.findByText(/about 15, 0 \(estimated between 14:01:00 and 14:02:00\)/);
// Between 14:02 and logout at 14:10 nothing was recorded for longer than a ping gap.
act(() => nav.go(`/x?session=s1&at=${T + 400}`));
await screen.findByText(/not observed \(offline, or not recorded\); last observation 14:02:00/);
await screen.findByText(/not seen; last seen 14:02:00/);
});

it("applies a typed range and refuses times the clocks skip", async () => {
Expand All @@ -125,7 +125,7 @@ it("applies a typed range and refuses times the clocks skip", async () => {
coreprotect: status,
});
render(<PlayerMovementPage uuid={UUID} />);
await screen.findByText(/recorded at 30/);
await screen.findByText(/· 30, 64, 0/);
fireEvent.click(screen.getByRole("tab", { name: "Time range" }));
fireEvent.change(screen.getByLabelText("From"), { target: { value: "2026-03-29T01:30" } });
fireEvent.click(screen.getByRole("button", { name: "Apply range" }));
Expand All @@ -136,7 +136,7 @@ it("applies a typed range and refuses times the clocks skip", async () => {
fireEvent.click(screen.getByRole("button", { name: "Apply range" }));
await waitFor(() => expect(getPlayerMovement).toHaveBeenCalledWith(UUID, T - 3600, T));
expect(nav.state.params.get("session")).toBeNull();
await screen.findByText(/No observations between Tue 6 Oct 2026, 13:00 → 14:00/);
await screen.findByText(/Nothing recorded between Tue 6 Oct 2026, 13:00 → 14:00/);
});

it("tells non-admins the page is not for them", async () => {
Expand All @@ -147,7 +147,7 @@ it("tells non-admins the page is not for them", async () => {

it("frames a new session only once its own answer is in", async () => {
render(<PlayerMovementPage uuid={UUID} />);
await screen.findByText(/recorded at 30, 64, 0/);
await screen.findByText(/14:10:00 · 30, 64, 0/);
expect(screen.getByTestId("map").dataset.fit).toBe("session:s1");
let answer: (value: unknown) => void = () => undefined;
vi.mocked(getSessionMovement).mockReturnValueOnce(new Promise((resolve) => (answer = resolve)) as never);
Expand Down
Loading