diff --git a/frontend/app/components/admin/AdminPanel.tsx b/frontend/app/components/admin/AdminPanel.tsx index 5813436f..6858cfa1 100644 --- a/frontend/app/components/admin/AdminPanel.tsx +++ b/frontend/app/components/admin/AdminPanel.tsx @@ -83,8 +83,7 @@ export default function AdminPanel() { return ( <>

- 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.

{/* The roster beside the lookup on wide screens; stacked below that. */} diff --git a/frontend/app/components/admin/EveryoneMovement.test.tsx b/frontend/app/components/admin/EveryoneMovement.test.tsx index 31c8990a..b1b36c33 100644 --- a/frontend/app/components/admin/EveryoneMovement.test.tsx +++ b/frontend/app/components/admin/EveryoneMovement.test.tsx @@ -76,7 +76,7 @@ afterEach(() => { it("compares everyone at the inspected moment and carries the view to a player", async () => { render(); - 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. @@ -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/); }); diff --git a/frontend/app/components/admin/EveryoneMovement.tsx b/frontend/app/components/admin/EveryoneMovement.tsx index 91b48ad5..6c2c76a4 100644 --- a/frontend/app/components/admin/EveryoneMovement.tsx +++ b/frontend/app/components/admin/EveryoneMovement.tsx @@ -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"; } } @@ -255,11 +255,7 @@ function EveryoneRange({ viewSwitch }: { viewSwitch: ReactNode }) { panel={ <> {viewSwitch} -

- {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. -

+

Lines between positions are estimates. Views are logged.

{data ? : null} {notice ?

{notice}

: null} {load.kind === "failed" ? ( @@ -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} @@ -327,17 +323,16 @@ function EveryoneRange({ viewSwitch }: { viewSwitch: ReactNode }) { <> {data ? ( - 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 ) : null} {data && completeFrom > since ? (

- 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)}.

) : null} {data && data.pings_since !== null && data.pings_since > since ? ( -

Before {formatMoment(data.pings_since)} there are no available position observations.

+

Before {formatMoment(data.pings_since)}, only login and logout locations.

) : null}
))} - {data && !all.length ?
  • Nobody was observed in this range.
  • : null} + {data && !all.length ?
  • Nobody was seen in this range.
  • : null}
    diff --git a/frontend/app/components/admin/EveryoneNow.test.tsx b/frontend/app/components/admin/EveryoneNow.test.tsx index 806f6467..1da5c4be 100644 --- a/frontend/app/components/admin/EveryoneNow.test.tsx +++ b/frontend/app/components/admin/EveryoneNow.test.tsx @@ -88,7 +88,7 @@ afterEach(() => { it("opens on where everyone online is now, and refreshes", async () => { render(); - 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(); @@ -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(); - 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); @@ -120,7 +120,7 @@ it("keeps the last positions when a refresh fails", async () => { it("switches between now and a time range", async () => { render(); - 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"); @@ -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(); - await screen.findByText(/2 players online/); + await screen.findByText(/2 players seen recently/); let answerSlow: (value: Awaited>) => void = () => {}; const first = await vi.mocked(getLatestMovement).mock.results[0].value; vi.mocked(getLatestMovement) diff --git a/frontend/app/components/admin/EveryoneNow.tsx b/frontend/app/components/admin/EveryoneNow.tsx index 52d77377..de55049b 100644 --- a/frontend/app/components/admin/EveryoneNow.tsx +++ b/frontend/app/components/admin/EveryoneNow.tsx @@ -159,12 +159,7 @@ export default function EveryoneNow({ viewSwitch = null }: { viewSwitch?: ReactN panel={ <> {viewSwitch} -

    - {data?.coreprotect.server_label ? `${data.coreprotect.server_label} · ` : ""} - Where everyone online is now: each player’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. -

    +

    Positions update about once a minute. Views are logged.

    {notice ?

    {notice}

    : null} {load.kind === "ready" && load.error ? (
    @@ -199,7 +194,7 @@ export default function EveryoneNow({ viewSwitch = null }: { viewSwitch?: ReactN <>

    {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 )}` @@ -236,7 +231,7 @@ export default function EveryoneNow({ viewSwitch = null }: { viewSwitch?: ReactN ))} - {data && !positions.length ?

  • Nobody is online.
  • : null} + {data && !positions.length ?
  • Nobody seen recently.
  • : null} } diff --git a/frontend/app/components/admin/MovementControls.tsx b/frontend/app/components/admin/MovementControls.tsx index 62350335..1969e564 100644 --- a/frontend/app/components/admin/MovementControls.tsx +++ b/frontend/app/components/admin/MovementControls.tsx @@ -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, @@ -254,7 +253,7 @@ export function RangeForm({ } }} /> - Keep up to date (moves the range with the clock) + Move range with the clock {error ? (

    @@ -262,8 +261,7 @@ export function RangeForm({

    ) : null}

    - Showing {describeSpan(value.from, value.to)} ·{" "} - {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)}` : ""}

    @@ -366,11 +364,11 @@ export function Timeline({
    - observed + seen not - observed (offline, or not recorded) + seen (not proof of offline) {hatchEnd !== null ? ( @@ -420,7 +418,7 @@ export function InspectBar({
    {/* The box on its own row, the steps together below it: nothing beside a date box to run into. */}
    {error ? ( @@ -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()) }); }} > - Pin a place on the overworld map (for a report) + Pin a place in the overworld
    setX(e.target.value)} /> setZ(e.target.value)} /> diff --git a/frontend/app/components/admin/MovementMap.tsx b/frontend/app/components/admin/MovementMap.tsx index b60c1c46..2d0051cb 100644 --- a/frontend/app/components/admin/MovementMap.tsx +++ b/frontend/app/components/admin/MovementMap.tsx @@ -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 }, @@ -677,8 +677,8 @@ export default function MovementMap({ ) : null} {latest ? ( - last recorded - position (once a minute) + last + position ) : ( <> @@ -694,10 +694,10 @@ export default function MovementMap({ ) : null} recorded - estimate or last seen + estimated or last seen - unobserved transition + route unknown logged in logged out diff --git a/frontend/app/components/admin/PlayerActivity.tsx b/frontend/app/components/admin/PlayerActivity.tsx index 1fc02469..54684919 100644 --- a/frontend/app/components/admin/PlayerActivity.tsx +++ b/frontend/app/components/admin/PlayerActivity.tsx @@ -37,7 +37,7 @@ export const KIND_LABELS: Record = { chat: "Chat", }; -const SOURCE_LABELS: Record = { mmoitems: "MMOItems", mythicmobs: "MythicMobs" }; +const SOURCE_LABELS: Record = { mmoitems: "Custom item", mythicmobs: "Custom mob" }; // Each channel in the colour its in-game tag uses (RPCharacters' chat.yml). const CHANNEL_COLOURS: Record = { @@ -49,7 +49,7 @@ function ChannelTag({ channel, inferred = false }: { channel: string; inferred?: const colour = CHANNEL_COLOURS[channel] ?? "var(--tfmc-mist)"; return ( @@ -322,9 +322,7 @@ export default function PlayerActivity({ uuid }: { uuid: string }) {

    Recent activity

    - {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."}

    {/* One row that scrolls sideways on a phone; wraps where there is room. */}
    { it("opens the newest session and inspects where it ended", async () => { render(); - 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); @@ -109,14 +109,14 @@ it("opens the newest session and inspects where it ended", async () => { it("steps between observations and explains estimates", async () => { render(); - 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 () => { @@ -125,7 +125,7 @@ it("applies a typed range and refuses times the clocks skip", async () => { coreprotect: status, }); render(); - 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" })); @@ -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 () => { @@ -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(); - 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); diff --git a/frontend/app/components/admin/PlayerMovementPage.tsx b/frontend/app/components/admin/PlayerMovementPage.tsx index 2281f5e4..bb85106f 100644 --- a/frontend/app/components/admin/PlayerMovementPage.tsx +++ b/frontend/app/components/admin/PlayerMovementPage.tsx @@ -1,6 +1,5 @@ "use client"; -import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -299,12 +298,9 @@ export default function PlayerMovementPage({ uuid }: { uuid: string }) { panel={ <>

    - {name ?? "…"} · movement + {name ?? "…"}

    -

    - {view?.coreprotect.server_label ? `${view.coreprotect.server_label} · ` : ""} - Positions are recorded once a minute; lines between them are estimates. Admins only; each view is logged. -

    +

    Lines between positions are estimates. Views are logged.

    {view ? : null} {notice ?

    {notice}

    : null} {load.kind === "failed" ? ( @@ -380,7 +376,7 @@ export default function PlayerMovementPage({ uuid }: { uuid: string }) { since={since} until={until} unknownUntil={unknownUntil} - unknownLabel={view.completeFrom > since ? "earlier observations omitted" : "before available position observations"} + unknownLabel={view.completeFrom > since ? "too many to show" : "login and logout locations only"} bands={observedBands(inWindow).map(([a, b]) => ({ from: a, to: b }))} cursor={cursor} onCursor={setCursor} @@ -398,7 +394,7 @@ export default function PlayerMovementPage({ uuid }: { uuid: string }) {
    {!view.points.length ? (

    - No observations between {describeSpan(since, until)}.{" "} + Nothing recorded between {describeSpan(since, until)}.{" "} {mode === "range" ? (

    ) : null} - - ← Back to the profile - } /> @@ -488,7 +477,7 @@ function Readout({ cursor: number; mapWorld: string; }) { - const when = `Inspecting ${formatClock(cursor, true)}`; + const when = formatClock(cursor, true); const where = (world: string | null) => (world === mapWorld ? "" : ` in ${worldLabel(world)}`); switch (found.kind) { case "observed": { @@ -496,7 +485,7 @@ function Readout({ return (
    - {when} · recorded at {s.x}, {s.y}, {s.z} + {when} · {s.x}, {s.y}, {s.z} {where(found.world)} {found.world === mapWorld ? : null} @@ -506,15 +495,15 @@ function Readout({ case "estimated": return ( - {when} · estimated between observations at {formatClock(found.before.time, true)} and{" "} - {formatClock(found.after.time, true)}: near {Math.round(found.x)}, {Math.round(found.z)} + {when} · about {Math.round(found.x)}, {Math.round(found.z)} (estimated between{" "} + {formatClock(found.before.time, true)} and {formatClock(found.after.time, true)}) {where(found.world)} ); case "unobserved": return ( - {when} · position unknown: an unobserved transition from {found.before.x}, {found.before.z} ( + {when} · unknown; moved from {found.before.x}, {found.before.z} ( {formatClock(found.before.time, true)}) to {found.after.x}, {found.after.z} ({formatClock(found.after.time, true)}) {where(found.world)} @@ -522,7 +511,7 @@ function Readout({ case "stale": return ( - {when} · position unknown; last observed {formatDuration(cursor - found.before.time).toLowerCase()} earlier at{" "} + {when} · unknown; last seen {formatDuration(cursor - found.before.time).toLowerCase()} earlier at{" "} {found.before.x}, {found.before.z} {where(found.world)} @@ -530,8 +519,8 @@ function Readout({ default: return ( - {when} · not observed (offline, or not recorded) - {found.before ? `; last observation ${formatClock(found.before.time, true)}` : ""}. + {when} · not seen + {found.before ? `; last seen ${formatClock(found.before.time, true)}` : ""}. ); } diff --git a/frontend/app/components/admin/PlayerProfile.test.tsx b/frontend/app/components/admin/PlayerProfile.test.tsx index 62160f71..2c590aae 100644 --- a/frontend/app/components/admin/PlayerProfile.test.tsx +++ b/frontend/app/components/admin/PlayerProfile.test.tsx @@ -135,7 +135,7 @@ it("groups sessions by day and hedges ones without a logout", async () => { expect(within(sessions).getByText(/^No logout recorded · last seen \d\d:\d\d$/)).toBeTruthy(); expect(within(sessions).queryByText(/Logged out/)).toBeNull(); expect(within(sessions).queryByRole("link")).toBeNull(); - expect(within(sessions).getByText(/^Retained session records start/)).toBeTruthy(); + expect(within(sessions).getByText(/^Server session history starts/)).toBeTruthy(); }); it("links each session to its route for those who may see movement", async () => { @@ -185,7 +185,7 @@ it("reports CoreProtect problems per section", async () => { nav.query = "tab=sessions"; render(); const sessions = await screen.findByRole("region", { name: "Sessions" }); - expect(await within(sessions).findByText(/CoreProtect took too long/)).toBeTruthy(); + expect(await within(sessions).findByText(/Activity history took too long/)).toBeTruthy(); }); it.each([ @@ -228,7 +228,7 @@ it("keeps rows and the cursor when a later page is unavailable", async () => { entries: [], next: null, searched_to: null, kinds: KINDS, shows_messages: false, coreprotect: { status: "unavailable", reason: "busy" }, }); fireEvent.click(within(feed).getByRole("button", { name: "Load more" })); - expect(await within(feed).findByText(/CoreProtect is busy/)).toBeTruthy(); + expect(await within(feed).findByText(/Activity history is busy/)).toBeTruthy(); expect(within(feed).getByText("stone")).toBeTruthy(); vi.mocked(getPlayerActivity).mockResolvedValueOnce({ @@ -239,7 +239,7 @@ it("keeps rows and the cursor when a later page is unavailable", async () => { expect(await within(feed).findByText("oak_door")).toBeTruthy(); expect(getPlayerActivity).toHaveBeenLastCalledWith(UUID, { before: "cursor-1", kinds: undefined }); expect(within(feed).getByText("stone")).toBeTruthy(); - expect(within(feed).queryByText(/CoreProtect is busy/)).toBeNull(); + expect(within(feed).queryByText(/Activity history is busy/)).toBeNull(); }); it("retries a failed sessions page from the same cursor", async () => { @@ -258,7 +258,7 @@ it("retries a failed sessions page from the same cursor", async () => { fireEvent.click(await within(sessions).findByRole("button", { name: "Load more sessions" })); expect(await within(sessions).findByText("That page link has expired. Reload to start again.")).toBeTruthy(); expect(within(sessions).getByText("End unknown")).toBeTruthy(); - expect(within(sessions).queryByText(/Retained session records/)).toBeNull(); + expect(within(sessions).queryByText(/Server session history starts/)).toBeNull(); fireEvent.click(within(sessions).getByRole("button", { name: "Try again" })); await waitFor(() => expect(getPlayerSessions).toHaveBeenLastCalledWith(UUID, "s-1")); }); @@ -277,7 +277,7 @@ it("shows chat and whole commands to admins, and says views are logged", async ( expect(within(feed).getByText("/msg Bob hello")).toBeTruthy(); expect(within(feed).getByText("(cut short)")).toBeTruthy(); expect(within(feed).getByRole("button", { name: "Chat" })).toBeTruthy(); - expect(within(feed).getByText(/Your views of these are logged/)).toBeTruthy(); + expect(within(feed).getByText(/Views are logged/)).toBeTruthy(); }); it("tags chat with its channel, and tells moderators only the channel", async () => { @@ -301,7 +301,7 @@ it("tags chat with its channel, and tells moderators only the channel", async () it("keeps moderators' note when messages are not included", async () => { render(); const feed = await screen.findByRole("region", { name: "Recent activity" }); - expect(await within(feed).findByText(/Chat, command arguments and sign text are not shown/)).toBeTruthy(); + expect(await within(feed).findByText(/Chat, command details and sign text are hidden/)).toBeTruthy(); expect(within(feed).queryByRole("button", { name: "Chat" })).toBeNull(); }); @@ -345,11 +345,11 @@ it("names things properly, merges block runs and calls the world by its name", a expect(within(feed).getByText(/4052, 64, 2$/)).toBeTruthy(); expect(within(feed).getByText("Mouse Plush")).toBeTruthy(); - expect(within(feed).getByText("MMOItems")).toBeTruthy(); + expect(within(feed).getByText("Custom item")).toBeTruthy(); expect(within(feed).queryByText(/MISC:MOUSE_PLUSH/)).toBeNull(); const plush = within(feed).getAllByRole("button", { name: /Details/ })[0]; fireEvent.click(plush); - expect(within(feed).getByText("MMOItems id MISC:MOUSE_PLUSH")).toBeTruthy(); + expect(within(feed).getByText("Custom item id MISC:MOUSE_PLUSH")).toBeTruthy(); expect(within(feed).getByText("Based on Paper")).toBeTruthy(); expect(within(feed).getByText("“Fang”")).toBeTruthy(); diff --git a/frontend/app/components/admin/PlayerProfile.tsx b/frontend/app/components/admin/PlayerProfile.tsx index e3bc41da..4a3ac549 100644 --- a/frontend/app/components/admin/PlayerProfile.tsx +++ b/frontend/app/components/admin/PlayerProfile.tsx @@ -139,7 +139,7 @@ export default function PlayerProfile({ uuid }: { uuid: string }) { `@${profile.discord.discord_username}` ) : ( - Handle not known yet (filled in when they sign in to the website) · ID {profile.discord.discord_user_id} + Not known yet · ID {profile.discord.discord_user_id} )} @@ -310,7 +310,7 @@ function Sessions({ uuid, movement }: { uuid: string; movement: boolean }) { ) : rows.length && historyStart && state === "ready" ? ( // The oldest session row CoreProtect still holds for anyone, not this player's first session. -

    Retained session records start {formatDate(historyStart)}.

    +

    Server session history starts {formatDate(historyStart)}.

    ) : null}
    ); diff --git a/frontend/app/components/admin/PlayersDirectory.test.tsx b/frontend/app/components/admin/PlayersDirectory.test.tsx index 27010158..55c3ae1e 100644 --- a/frontend/app/components/admin/PlayersDirectory.test.tsx +++ b/frontend/app/components/admin/PlayersDirectory.test.tsx @@ -94,7 +94,7 @@ it("lists everyone under Minecraft by default", async () => { expect(within(screen.getByRole("link", { name: "Justin" }).closest("tr")!).queryByText("Not linked")).toBeNull(); expect(within(screen.getByRole("link", { name: "Quiet" }).closest("tr")!).getByText("Seen just now")).toBeTruthy(); expect(within(screen.getByRole("link", { name: "22222222-2222-2222-2222-222222222222" }).closest("tr")!).getByText("Never")).toBeTruthy(); - expect(screen.getByText("243 players · activity from Vardera")).toBeTruthy(); + expect(screen.getByText("243 players")).toBeTruthy(); expect(screen.queryByRole("tab", { name: "Activity" })).toBeNull(); expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["Minecraft", "Discord", "Character"]); expect(screen.getByRole("tab", { name: "Minecraft" }).getAttribute("aria-selected")).toBe("true"); @@ -146,7 +146,7 @@ it("leads the Discord table with the handle and counts unlinked players", async expect(lead(row)).toContain("@hazelstone"); expect(lead(row)).toContain("Enzo"); expect(within(row).getByText("2 h ago")).toBeTruthy(); - expect(screen.getByText("1 linked player · activity from Vardera")).toBeTruthy(); + expect(screen.getByText("1 linked player")).toBeTruthy(); expect(screen.getByText(/34 players without a Discord link aren’t listed here/)).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "See everyone under Minecraft" })); @@ -257,7 +257,7 @@ it("says when CoreProtect cannot be read, and that last seen is unknown", async }), }); renderDirectory(); - expect(await screen.findByText(/CoreProtect is busy.*Only linked players and characters are listed/)).toBeTruthy(); + expect(await screen.findByText(/Activity history is busy.*Only linked players and characters are listed/)).toBeTruthy(); expect(within(screen.getByRole("link", { name: "Linked" }).closest("tr")!).getByText("Unknown")).toBeTruthy(); }); diff --git a/frontend/app/components/admin/PlayersDirectory.tsx b/frontend/app/components/admin/PlayersDirectory.tsx index d752b156..09fb894b 100644 --- a/frontend/app/components/admin/PlayersDirectory.tsx +++ b/frontend/app/components/admin/PlayersDirectory.tsx @@ -287,7 +287,7 @@ function countLine(data: PlayerDirectory): string { : n === 1 ? "1 player" : `${n} players`; const source = data.coreprotect.server_label; if (!source) return counted; - return data.view === "character" ? `${counted} on ${source}` : `${counted} · activity from ${source}`; + return data.view === "character" ? `${counted} on ${source}` : counted; } export default function PlayersDirectory({ initialQuery, initialListing, initialPage }: Props) { @@ -398,7 +398,7 @@ export default function PlayersDirectory({ initialQuery, initialListing, initial setQuery(e.target.value); setPage(1); }} - placeholder="Search by Minecraft, Discord (handle or nickname) or character name, or UUID" + placeholder="Minecraft, Discord or character name, or UUID" autoComplete="off" spellCheck={false} className={inputClass} diff --git a/frontend/app/components/admin/RailMap.tsx b/frontend/app/components/admin/RailMap.tsx index a9f6e6d3..e8aa78ce 100644 --- a/frontend/app/components/admin/RailMap.tsx +++ b/frontend/app/components/admin/RailMap.tsx @@ -257,7 +257,7 @@ export default function RailMap({ mapId, network, highlight = null, focus = null stroke={colour} strokeWidth={2.5 * unit} > - {`${label}: ${blocks(s.to - s.from)}, ${Math.round(s.from).toLocaleString("en-GB")} blocks along`} + {`${label}: ${blocks(s.to - s.from)}`} )) @@ -310,9 +310,7 @@ export default function RailMap({ mapId, network, highlight = null, focus = null const capital = stop.kind === "faction_capital"; return ( - {`${stop.name}${capital ? " (faction capital)" : ""}\n${Math.round(stop.along).toLocaleString( - "en-GB" - )} blocks along the track · settlement ${blocks(stop.distance)} away`} + {`${stop.name}${capital ? " (faction capital)" : ""}\nSettlement ${blocks(stop.distance)} away`} { render(); expect(await screen.findByText("Thalenthyr – Drammen")).toBeTruthy(); expect(getRailNetwork).toHaveBeenCalledWith("main"); - expect(screen.getByText(/last saved them, at Wed 7 Oct 2026, 20:30/)).toBeTruthy(); + expect(screen.getByText(/Updated Wed 7 Oct 2026, 20:30/)).toBeTruthy(); expect(screen.getByText("Broken: 22 blocks")).toBeTruthy(); expect(screen.getByText("capital · 0, 0")).toBeTruthy(); expect(screen.getByText("Unnamed line")).toBeTruthy(); @@ -88,7 +88,7 @@ it("says when nothing needs repair", async () => { it("explains a site that cannot read the tracks", async () => { vi.mocked(getRailNetwork).mockResolvedValue({ ...NETWORK, status: "not_configured", lines: [], tracks: [], stops: [] }); render(); - expect(await screen.findByText("This site isn’t set up to read the rail network yet.")).toBeTruthy(); + expect(await screen.findByText("Rail data isn’t available on this site.")).toBeTruthy(); expect(screen.queryByTestId("map")).toBeNull(); }); diff --git a/frontend/app/components/admin/RailOverview.tsx b/frontend/app/components/admin/RailOverview.tsx index 6d7507b9..50403699 100644 --- a/frontend/app/components/admin/RailOverview.tsx +++ b/frontend/app/components/admin/RailOverview.tsx @@ -125,15 +125,11 @@ export default function RailOverview() { panel={ <>

    - The tracks as VehicleFramework last saved them - {data.updated_at ? `, at ${formatMoment(data.updated_at)}` : ""}. A stop is a settlement whose provinces a - track crosses; it sits where the track comes closest to the settlement. + {data.updated_at ? `Updated ${formatMoment(data.updated_at)}. ` : ""}Stops are where a track passes closest to a + settlement.

    {data.unreadable_files ? ( -

    - {data.unreadable_files} track {data.unreadable_files === 1 ? "file" : "files"} couldn’t be read, probably - mid-save. Reload in a moment. -

    +

    Some track couldn’t be loaded. Reload in a moment.

    ) : null} {problems.length ? (
    diff --git a/frontend/app/components/admin/ranks/RankGroup.tsx b/frontend/app/components/admin/ranks/RankGroup.tsx index 0d73f906..ff613e4f 100644 --- a/frontend/app/components/admin/ranks/RankGroup.tsx +++ b/frontend/app/components/admin/ranks/RankGroup.tsx @@ -1,6 +1,5 @@ "use client"; -import Link from "next/link"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { AccountApiError } from "../../../../lib/account/api"; @@ -158,9 +157,7 @@ export default function RankGroup({ name }: { name: string }) { Players in {group.name}{" "} · {(members?.total ?? group.members).toLocaleString()} -

    - Players with a direct {group.name} entry, in any context. Chips show their other directly held groups. -

    +

    Direct members only. Chips show their other groups.

    {members ? ( {group.name}. ), - warning: `${group.members} players hold it directly and keep a dangling entry; ${group.children.length} groups inherit it. Remove it from tracks first.`, + warning: `${group.members} players hold it directly and ${group.children.length} groups inherit it; deleting leaves its entries on those players. Remove it from tracks first.`, ops: [{ op: "delete_group" }], label: "Delete group", }) @@ -212,11 +209,6 @@ export default function RankGroup({ name }: { name: string }) { )}
    ) : null} -

    - - ← All groups - -

    ); } @@ -330,7 +322,7 @@ function NodesPanel({

    Permissions and settings

    - {nodes.length} nodes + {nodes.length} entries
    {nodes.length > 8 ? ( { const groups = await screen.findByRole("region", { name: "Groups" }); fireEvent.change(within(groups).getByLabelText("Add to group"), { target: { value: "noble" } }); fireEvent.click(within(groups).getByRole("button", { name: "Add" })); - expect(within(groups).getByText(/Patreon-managed/)).toBeTruthy(); + expect(within(groups).getByText(/Patreon may reset/)).toBeTruthy(); fireEvent.change(within(groups).getByLabelText("Reason (recorded)"), { target: { value: "Gift" } }); fireEvent.click(within(groups).getAllByRole("button", { name: "Add" }).at(-1)!); expect(await within(groups).findByText(/No server is applying rank changes/)).toBeTruthy(); diff --git a/frontend/app/components/admin/ranks/RankPlayer.tsx b/frontend/app/components/admin/ranks/RankPlayer.tsx index a2d30f18..54f310c7 100644 --- a/frontend/app/components/admin/ranks/RankPlayer.tsx +++ b/frontend/app/components/admin/ranks/RankPlayer.tsx @@ -25,7 +25,7 @@ import { buttonClass, chipClass, headingClass, inputClass, mutedClass, panelClas /** Servers that share this LuckPerms storage; a node may apply to just one. */ export const SERVERS = ["main", "dev", "tutorial"] as const; const PATREON_NOTE = - "Patreon-managed: for a linked patron, the Patreon sync resets permanent all-server grants of this group within 30 minutes."; + "For linked patrons, Patreon may reset permanent all-server changes to this group within 30 minutes."; type Load = { kind: "loading" } | { kind: GateKind } | { kind: "failed"; message: string } | { kind: "ready"; data: LpPlayer }; @@ -203,7 +203,7 @@ export default function RankPlayer({ uuid }: { uuid: string }) {

    Groups

    {player.inherits.length ? ( -

    With inheritance, everywhere: {player.inherits.join(", ")}.

    +

    Across all servers, including inherited: {player.inherits.join(", ")}.

    ) : null} {groupNodes.length ? (
      @@ -435,7 +435,7 @@ function PermissionsPanel({

      Permissions

      - {nodes.length} of their own; groups add more + {nodes.length} set directly
      {nodes.length > 8 ? ( - + {/* Side by side on wide screens: neither needs the full width. */}
      @@ -58,7 +58,7 @@ export default function RanksOverview({ initialQuery = "" }: { initialQuery?: st ); } -function PlayerSearch({ initialQuery, total }: { initialQuery: string; total: number }) { +function PlayerSearch({ initialQuery }: { initialQuery: string }) { const [query, setQuery] = useState(initialQuery); const [page, setPage] = useState(null); const [busy, setBusy] = useState(false); @@ -106,7 +106,7 @@ function PlayerSearch({ initialQuery, total }: { initialQuery: string; total: nu id="ranks-search" value={query} onChange={(e) => setQuery(e.target.value)} - placeholder="Find a player: Minecraft name or UUID" + placeholder="Minecraft name or UUID" autoComplete="off" spellCheck={false} className={`${inputClass} min-w-0 flex-1 basis-64`} @@ -115,7 +115,7 @@ function PlayerSearch({ initialQuery, total }: { initialQuery: string; total: nu {busy ? "Searching…" : "Find"}

      - {total.toLocaleString()} players have something set in LuckPerms. Part of a name is enough. + Part of a name is enough.

      {error ? ( @@ -268,7 +268,7 @@ function GroupsPanel({ data, onChanged }: { data: LpOverview; onChanged: () => P ) : null}

      - Heaviest first: a player shows the rank of the heaviest group they inherit. Player counts are direct assignments only. + Highest weight first. A player shows the rank of their highest-weight group. Player counts are direct members only.

      {creating ? (
      diff --git a/frontend/app/components/admin/ranks/parts.tsx b/frontend/app/components/admin/ranks/parts.tsx index f60ed9f0..e24897e8 100644 --- a/frontend/app/components/admin/ranks/parts.tsx +++ b/frontend/app/components/admin/ranks/parts.tsx @@ -16,25 +16,29 @@ function seconds(stamp: string | null): number | null { return stamp ? Date.parse(stamp) / 1000 : null; } -/** Where the data comes from, how fresh it is, and whether changes can be made. */ +/** Says only when something is off: no data yet, stale data, or changes that can't be made. */ export function StatusLine({ status, rights }: { status: LpStatus; rights: LpRights }) { if (!status.has_snapshot) { return (

      - No server has sent LuckPerms data to this site yet. It appears once TFMCWeb’s LuckPerms bridge is on. + Ranks aren’t available yet.

      ); } const checked = seconds(status.checked_at); const stale = checked !== null && Date.now() / 1000 - checked > 180; + const notes = [ + stale ? `Last checked ${formatAgo(checked).toLowerCase()}.` : null, + rights.read_only + ? "View only. Change ranks on the main site." + : status.applying + ? null + : "Rank changes are unavailable right now.", + ].filter(Boolean); + if (!notes.length) return null; return (

      - LuckPerms from {status.server ?? "the server"}, checked {formatAgo(checked).toLowerCase()}.{" "} - {rights.read_only - ? "This site only shows ranks; change them on the main site." - : status.applying - ? "Changes apply within seconds." - : "No server is applying changes right now, so nothing can be changed."} + {notes.join(" ")}

      ); } diff --git a/frontend/lib/admin/api.ts b/frontend/lib/admin/api.ts index 89e0f93d..02db3dd3 100644 --- a/frontend/lib/admin/api.ts +++ b/frontend/lib/admin/api.ts @@ -60,7 +60,7 @@ const ERROR_MESSAGES: Record = { player_not_found: "No player with that UUID has been seen.", bad_uuid: "That isn’t a Minecraft UUID.", bad_session: "That session link isn’t valid.", - session_gone: "That session is no longer in CoreProtect’s records.", + session_gone: "That session is no longer available.", bad_window: "Choose a time range of up to 7 days for one player, or 24 hours for everyone.", bad_cursor: "That page link has expired. Reload to start again.", bad_kinds: "That filter isn’t available.", @@ -296,12 +296,12 @@ export type ActivityPage = { }; const COREPROTECT_REASONS: Record = { - not_configured: "CoreProtect isn’t connected to this site.", - missing: "The CoreProtect database couldn’t be found.", - cannot_open: "The CoreProtect database couldn’t be opened.", - busy: "CoreProtect is busy. Try again in a moment.", - timeout: "CoreProtect took too long to answer. Try again in a moment.", - error: "CoreProtect couldn’t be read.", + not_configured: "Activity history isn’t available on this site.", + missing: "Activity history isn’t available right now.", + cannot_open: "Activity history isn’t available right now.", + busy: "Activity history is busy. Try again in a moment.", + timeout: "Activity history took too long. Try again in a moment.", + error: "Activity history isn’t available right now.", }; export function coreProtectMessage(status: CoreProtectStatus): string | null { diff --git a/frontend/lib/admin/luckperms.ts b/frontend/lib/admin/luckperms.ts index 10e4e8f7..37efcfeb 100644 --- a/frontend/lib/admin/luckperms.ts +++ b/frontend/lib/admin/luckperms.ts @@ -221,8 +221,8 @@ export const LP_ERRORS: Record = { bad_group_name: "Use lowercase letters, numbers, _, + or - for group names.", bad_track_name: "Use lowercase letters, numbers, _, + or - for track names.", bad_track_groups: "List each group once.", - player_not_found: "LuckPerms has no record of that player.", - save_failed: "LuckPerms couldn’t save the change.", + player_not_found: "No rank record found for that player.", + save_failed: "The rank change couldn’t be saved.", no_result: "The server never reported back.", no_longer_allowed: "Cancelled: it was no longer allowed when the server picked it up (rights or groups changed).", expired: "The server didn’t pick it up in time.", diff --git a/frontend/lib/admin/movement.test.ts b/frontend/lib/admin/movement.test.ts index 5c2f1987..1adf554f 100644 --- a/frontend/lib/admin/movement.test.ts +++ b/frontend/lib/admin/movement.test.ts @@ -225,9 +225,9 @@ describe("session labels", () => { it("says only what the record shows", () => { expect(sessionEndLabel(session("logout", at(14, 15), at(14, 15)))).toBe("Logged out 14:15"); - expect(sessionEndLabel(session("open", null, at(14, 14)))).toBe("Probably online · last observed 14:14"); + expect(sessionEndLabel(session("open", null, at(14, 14)))).toBe("Probably online · last seen 14:14"); expect(sessionEndLabel(session("last_observed", at(14, 15), at(14, 15)))).toBe( - "Last observed 14:15 · logout not recorded" + "Last seen 14:15 · no logout recorded" ); expect(sessionEndLabel(session("unknown", null, at(13, 2)))).toBe("End unknown"); expect(sessionSpanLabel(session("logout", at(14, 15), at(14, 15)))).toBe("13:02 → 14:15"); diff --git a/frontend/lib/admin/movement.ts b/frontend/lib/admin/movement.ts index cc210831..6216d89c 100644 --- a/frontend/lib/admin/movement.ts +++ b/frontend/lib/admin/movement.ts @@ -508,9 +508,9 @@ export function sessionEndLabel(session: PlayerSession): string { case "logout": return `Logged out ${formatClock(session.end!.time)}`; case "open": - return `Probably online · last observed ${formatClock(session.last_observed.time)}`; + return `Probably online · last seen ${formatClock(session.last_observed.time)}`; case "last_observed": - return `Last observed ${formatClock(session.last_observed.time)} · logout not recorded`; + return `Last seen ${formatClock(session.last_observed.time)} · no logout recorded`; default: return "End unknown"; } diff --git a/frontend/lib/admin/rail.ts b/frontend/lib/admin/rail.ts index d16dbb99..35fb69e8 100644 --- a/frontend/lib/admin/rail.ts +++ b/frontend/lib/admin/rail.ts @@ -67,8 +67,8 @@ export function lineColour(line: RailLine | undefined): string { } export function statusMessage(status: RailStatus): string | null { - if (status === "not_configured") return "This site isn’t set up to read the rail network yet."; - if (status === "missing") return "The rail network’s files can’t be found on the server."; + if (status === "not_configured") return "Rail data isn’t available on this site."; + if (status === "missing") return "Rail data isn’t available right now."; return null; }