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
9 changes: 7 additions & 2 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,5 +220,10 @@ on the existing capability / invited-user gates — they are not the source
file. See [club-library.md](club-library.md#ocad-source-files).

`events.push` (venue journal) stays on `eventProcedure` without an extra
capability check. Instance `users` and Eventor API-key admin stay
`adminProcedure`.
capability check. Instance `users` and Eventor API-key admin are
`adminProcedure`: `eventor.validateKey`, `eventor.setKey` and
`eventor.clearKey` back the **Settings → Eventor** tab
(`/settings?tab=eventor`), which is hidden from members like the Users
and Maintenance tabs. `eventor.keyStatus` / `eventor.getKey` are
`authedProcedure` — they only say whether a key exists, and the import
panel and registration dialog need that for any invited user.
4 changes: 3 additions & 1 deletion docs/bugfix-eventor-key-wiped-by-e2e.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ entry for the snapshot file. No production code changes.

## Recovery

Re-enter your Eventor API key from the competition selector once. Any
Re-enter your Eventor API key once under **Settings → Eventor**
(`/settings?tab=eventor`; it used to be entered from the competition
selector's import panel). Any
existing competitions linked to `prod` will start working immediately
(they only depend on the key being present in `oxygen.oxygen_settings`).

Expand Down
7 changes: 4 additions & 3 deletions docs/club-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ event. Identity (when `AUTH_MODE` is on) comes from the same invite-only

The library lives on the **Settings** page (`/settings`), reached from the
button under the event list on the start page. Its four tabs — Maps,
Controls, Classes, Groups — are open to any signed-in user; two further
tabs (Users, Maintenance) are instance-admin only and are described in
[authentication.md](authentication.md).
Controls, Classes, Groups — are open to any signed-in user; three further
tabs (Users, Eventor, Maintenance) are instance-admin only. Users and the
Eventor API keys are described in [authentication.md](authentication.md)
and [features.md](features.md#eventor-api-keys).

## Maps

Expand Down
12 changes: 7 additions & 5 deletions docs/e2e-eventor-stub.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,13 @@ which elements must be arrays). No Playwright config change is needed.

## Coverage this recovered

`e2e/eventor.spec.ts` → "should validate Eventor API key and show event
list" was `test.skip`ped with the comment *"requires a valid Eventor API
key and live network access"*. It now runs, and asserts against the
stub's organisation and event names so it fails if the list stops
reaching the UI.
`e2e/eventor.spec.ts` → "admin stores the key under Settings → Eventor
and the import panel lists events" (formerly "should validate Eventor
API key and show event list") was `test.skip`ped with the comment
*"requires a valid Eventor API key and live network access"*. It now
runs: it enters the key on the admin-only Settings → Eventor tab, then
asserts against the stub's organisation and event names on the selector's
import panel so it fails if the list stops reaching the UI.

## What is still not covered

Expand Down
25 changes: 24 additions & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,12 +273,35 @@ The components are in the tree and unit-tested, but no page currently feeds them

Every event lives in the single `oxygen` database, scoped by `event_id`. The selector is the landing page — pick one and the whole app re-scopes to that event.

The list is grouped into **Upcoming** (`date >= today`) and **Past**, denser than the original card so clubs can keep many events loaded. A search box matches name, slug, annotation, and custom event type. The type filter uses Oxygen's editable type catalogue, including club training, weekly course, and a stable Other bucket for custom labels.
The list is grouped into **Upcoming** (`date >= today`) and **Past**, denser than the original card so clubs can keep many events loaded. A search box matches name, slug, annotation, and custom event type. The type filter uses Oxygen's editable type catalogue, including club training, weekly course, and a stable Other bucket for custom labels. When a user is signed in, a **My events** toggle narrows the list to events they hold a direct Event admin grant on — the ones they created or were made co-admin of (`EventInfo.ownedByMe`). An instance admin sees every event but owns none of them, so the toggle is a genuine filter for them too.

Creating an event asks for name, type, and date. For events created by an
invited user, the selector also shows that creator. The old per-event MySQL
host fields are gone.

The selector is laid out for phones as much as for desktops. The
signed-in user chip and language switcher live in the footer, under the
action buttons, instead of crowding the header; the "Select an event to
manage" tagline is desktop-only; the search box takes its own row on
narrow screens so the type select cannot run off the edge; the creator
attribution collapses to the bare name; and the per-row delete icon is
always visible on touch devices (it is hover-revealed only where
`(hover: hover)` holds, since a phone has no hover to reveal it with).

#### Eventor API keys

The club's Eventor API keys — one for production, one for Test-Eventor —
are stored once per instance in `oxygen_settings` and shared by every
event. They are entered and removed under **Settings → Eventor**
(`/settings?tab=eventor`), which is an instance-admin tab; the
`eventor.validateKey` / `setKey` / `clearKey` mutations behind it are
`adminProcedure`, so a member cannot reach them through the API either.
Reading whether a key is configured (`eventor.keyStatus`) stays open to
any invited user because the import panel and the registration dialog
gate their UI on it. **Import from Eventor** on the selector therefore
no longer asks for a key: with none configured it links admins to the
Settings tab and tells members to ask an admin.

When `AUTH_MODE=proxy` (or `dev`), the selector and event shell require a
signed-in user. Identity comes from a trusted reverse-proxy header — Oxygen
never stores passwords. With `AUTH_AUTO_PROVISION=member` (Cloud Run),
Expand Down
19 changes: 17 additions & 2 deletions docs/map-color-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,26 @@ renders pass
`objects: file.objects.filter(o => Math.floor(o.sym / 1000) !== 601)`
into `ocadToSvg` / `objectsInWindow`. The composite keeps all objects.

**What the spec says.** ISOM 2017 Appendix 1 (chapter 4, colour order)
puts *Black* and *Blue 100 % lines and point symbols* **above** *Purple
(course setting)* — the lower purple — and ISOM 601 magnetic north lines
are black or blue 100 % line symbols. So by the letter of the IOF colour
order north lines sit **on top of** control circles and legs (still under
upper purple: control numbers, marked routes). `north_lines_below = true`
is therefore a deliberate deviation for legibility, not the default the
spec prescribes. The option is kept because some clubs prefer it, but it
is a base-map property: it is set on the club-library map card
(**Settings → Base maps**) and is no longer offered per event on the
course editor's map footer.

## UI / API

- Event map: `course.setMapColorStack` + MapPanel select
(`data-testid="map-color-profile"`) and north-lines checkbox.
- Club library: `clubMap.setColorStack` + Settings → Maps card controls.
(`data-testid="map-color-profile"`). The north-lines checkbox was
removed from the editor in September 2026 (see above); the API field
remains.
- Club library: `clubMap.setColorStack` + Settings → Maps card controls
(colour profile select + north-lines checkbox).
- `course.mapMetadata` exposes `colorProfile`, `northLinesBelow`,
`resolvedProfile`, `resolvedBy`, and `renderKey`.

Expand Down
46 changes: 45 additions & 1 deletion docs/mobile-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,47 @@ scroll the page underneath. A two-finger map gesture cancels any armed control
tap and suppresses editor selections for 400 ms after movement, giving both
fingers time to leave the screen. Descriptions start hidden below `sm`.

### Map toolbar buttons

Every toggle on the map toolbar — **Hide other controls**, **Auto slits**,
**Descriptions**, **Show progress** — is a `ToolbarButton`
(`packages/web/src/components/ToolbarButton.tsx`): an icon from
`map-icons.tsx` plus a label that is visible from `sm` up and collapses to the
icon alone below it. The label always feeds `aria-label` and the tooltip, so
the accessible name (and `getByRole("button", { name })` in E2E) is the same
on both breakpoints. Undo / redo are icon-only everywhere. On phones the
buttons get `p-2` and 20px icons — a ~36px touch target — instead of the
12px `⟲` / `⟳` glyphs the editor used to ship. Without this the row of text
buttons did not fit 390px and **Auto slits** rendered on top of
**Descriptions**.

The selected-control readout keeps the code and SRR/radio badges on phones
but hides the millimetre position (`hidden sm:inline`).

The **North lines under course purple** checkbox is no longer on the editor's
map footer. It is a property of the base map, not of one event's course work,
so it lives only on the club-library map card (**Settings → Base maps**). See
[map-color-stack.md](map-color-stack.md#north-lines-under-the-course) for what
the IOF colour order actually says about it.

### Map layout / template editor header

`MapLayoutEditor`'s header is one row from `sm` up and two rows below: title +
help + fullscreen/close first, then undo / redo / zoom / preview-course select
/ save status. The two row wrappers switch to `display: contents` at `sm`
(`sm:contents`), so their children join the header's flex line directly and
`order` puts the window controls last. The **Preview course** caption is gone;
the select carries the name as `aria-label` / `title`, and its "no course"
option reads as the label. On phones the "Rendering map preview…" pill is
spinner-only.

### Controls page toolbar

The count / AIR+ / awake-hours group and the **Program Controls** / **Read
Controls** buttons are two `flex-wrap` groups inside a wrapping row
(`data-testid="controls-toolbar"`), so on a phone the station buttons drop to
their own line instead of pushing the page wider than the screen.

## Shell header (narrow viewports)

Below the `sm` breakpoint (~640px):
Expand Down Expand Up @@ -152,7 +193,10 @@ to Google Accounts and the cross-origin OAuth response is rejected by CORS.
| Map gestures | `packages/web/src/components/MapViewer.tsx` |
| GPS locate | `packages/web/src/lib/locate-mode.ts`, `useGeolocationWatch` |
| Session recovery | `packages/web/src/lib/session-recovery.ts`, `CompetitionShell` |
| Map footer / height | `packages/web/src/components/MapPanel.tsx` |
| Map footer / height / toolbar | `packages/web/src/components/MapPanel.tsx` |
| Toolbar buttons + icons | `packages/web/src/components/ToolbarButton.tsx`, `map-icons.tsx` |
| Template editor header | `packages/web/src/components/MapLayoutEditor.tsx` |
| Controls page toolbar | `packages/web/src/pages/ControlsPage.tsx` |
| Shell header | `packages/web/src/pages/CompetitionShell.tsx` |
| Editor dismiss wire-up | `packages/web/src/pages/CourseEditorPage.tsx` |
| E2E | `e2e/mobile-layout.spec.ts`, `e2e/session-recovery.spec.ts` |
129 changes: 129 additions & 0 deletions e2e/event-selector.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ test.describe("Event selector", () => {

const owner = row.getByTestId("event-owner");
await expect(owner).toBeVisible();
// On a phone only the bare name is shown; "Created by" is desktop-only.
// (useInnerText so the display:none desktop span is not counted.)
await expect(owner).not.toContainText("Created by", { useInnerText: true });
const ownerName = (await owner.innerText()).trim();
expect(ownerName.length).toBeGreaterThan(0);
const eventType = row.getByTestId("event-type");
await expect(eventType).toHaveText("Competition");
await expect(row.getByText(/E2E_Row_Layout/)).toHaveCount(0);
Expand All @@ -59,6 +64,130 @@ test.describe("Event selector", () => {
expect(Math.abs(slugMid - ownerMid)).toBeLessThan(4);
// Creator is pushed to the right rather than jammed against the type.
expect(ownerBox.x).toBeGreaterThan(typeBox.x + typeBox.width);

// Widen back out: the full attribution returns.
await page.setViewportSize({ width: 1280, height: 800 });
await expect(owner).toHaveText(`Created by ${ownerName}`, { useInnerText: true });

// Clean up.
const createdRow = page.locator("li", { hasText: uniqueName });
await createdRow.hover();
await createdRow.getByTestId("event-delete").click();
await page.getByTestId("delete-event-confirm").click();
await expect(createdRow).toHaveCount(0);
});

test("phone layout: no tagline, filters fit the screen, chrome lives in the footer", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
await expect(page.getByText("My example tävling").first()).toBeVisible({
timeout: 10000,
});

// The "Select an event to manage" tagline is desktop-only.
await expect(page.getByText("Select an event to manage")).toBeHidden();

// The type select used to keep its widest option's intrinsic width and
// run off the right edge; now every filter control sits inside the
// viewport.
for (const id of ["event-search", "event-type-filter", "event-mine-filter"]) {
const box = (await page.getByTestId(id).boundingBox())!;
expect(box, id).not.toBeNull();
expect(box.x, `${id} left edge`).toBeGreaterThanOrEqual(0);
expect(box.x + box.width, `${id} right edge`).toBeLessThanOrEqual(390);
}

// Signed-in user and language switcher moved from the header to the
// footer, below the action buttons.
const footer = page.getByTestId("selector-footer");
await expect(footer.getByTestId("user-chip")).toBeVisible();
await expect(footer.getByRole("button", { name: /English|Svenska/ })).toBeVisible();
const footerBox = (await footer.boundingBox())!;
const logoBox = (await page.getByTestId("oxygen-logo").boundingBox())!;
expect(footerBox.y).toBeGreaterThan(logoBox.y + logoBox.height);
const settingsBox = (await page.getByTestId("settings-link").boundingBox())!;
expect(footerBox.y).toBeGreaterThan(settingsBox.y + settingsBox.height);

// The tagline comes back on a desktop viewport.
await page.setViewportSize({ width: 1280, height: 800 });
await expect(page.getByText("Select an event to manage")).toBeVisible();
});

test("delete button is always visible on a touch device", async ({ browser }) => {
// Desktop Chromium reports (hover: hover), so the icon is hover-revealed
// there; a touch context flips the media query and the icon must be
// visible without any hover at all.
const touch = await browser.newContext({
hasTouch: true,
isMobile: true,
viewport: { width: 390, height: 844 },
extraHTTPHeaders: { "x-forwarded-email": "e2e-admin@oxygen.test" },
});
try {
const page = await touch.newPage();
await page.goto("/");
const row = page.locator("li", { hasText: "My example tävling" }).first();
await expect(row).toBeVisible({ timeout: 10000 });
const del = row.getByTestId("event-delete");
await expect(del).toBeVisible();
const opacity = await del.evaluate((el) => getComputedStyle(el).opacity);
expect(Number(opacity)).toBe(1);
const box = (await del.boundingBox())!;
expect(box.x + box.width).toBeLessThanOrEqual(390);
} finally {
await touch.close();
}
});

test("delete button stays hover-revealed on a mouse device", async ({ page }) => {
await page.goto("/");
const row = page.locator("li", { hasText: "My example tävling" }).first();
await expect(row).toBeVisible({ timeout: 10000 });
const del = row.getByTestId("event-delete");
expect(Number(await del.evaluate((el) => getComputedStyle(el).opacity))).toBe(0);
await row.hover();
await expect
.poll(async () => Number(await del.evaluate((el) => getComputedStyle(el).opacity)))
.toBe(1);
});

test("My events keeps only events the signed-in user administers", async ({ page }) => {
// Seed events have no owning user; one we create is auto-granted to us.
const uniqueName = `E2E Mine ${Date.now()}`;
await page.goto("/");
await page.getByRole("button", { name: /New Event/ }).click();
await page.getByPlaceholder(/Klubbmästerskap/).fill(uniqueName);
await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible({
timeout: 15000,
});

await page.goto("/");
await expect(page.getByText(uniqueName).first()).toBeVisible({ timeout: 10000 });
await expect(page.getByText("My example tävling").first()).toBeVisible();

const mine = page.getByTestId("event-mine-filter");
await expect(mine).toHaveAttribute("aria-pressed", "false");
await mine.click();
await expect(mine).toHaveAttribute("aria-pressed", "true");
await expect(page.getByText(uniqueName).first()).toBeVisible();
await expect(page.getByText("My example tävling")).toHaveCount(0);

// Composes with search; "Clear filters" resets the toggle too.
await page.getByTestId("event-search").fill("example");
await expect(page.getByText("No events match the current search.")).toBeVisible();
await page.getByTestId("clear-event-filters").click();
await expect(mine).toHaveAttribute("aria-pressed", "false");
await expect(page.getByText("My example tävling").first()).toBeVisible();

// Clean up.
const createdRow = page.locator("li", { hasText: uniqueName });
await createdRow.hover();
await createdRow.getByTestId("event-delete").click();
await page.getByTestId("delete-event-confirm").click();
await expect(createdRow).toHaveCount(0);
});

test("manifest provides authenticated install metadata and PNG icons", async ({ page, request }) => {
Expand Down
Loading
Loading