diff --git a/apps/web/e2e/demo.ts b/apps/web/e2e/demo.ts
index 21d769c..75b4d58 100644
--- a/apps/web/e2e/demo.ts
+++ b/apps/web/e2e/demo.ts
@@ -1,3 +1,4 @@
+import type { Snapshot as RecentGames } from '../src/games/games';
import type { Home } from '../src/home/api';
import type { Snapshot } from '../src/jira/tickets';
import type { State } from '../src/nudge/nudge';
@@ -172,3 +173,35 @@ export const report: Report = {
week: { loggedHours: 15.75, targetHours: 40 },
searched: '2026-10-21T00:38:00Z',
};
+
+/**
+ * The games a made-up user played in the last 14 days, the last played first, as the games service found them ten
+ * minutes before the tests' time: one game today, one yesterday on a Nintendo console, which has no trophies, one with
+ * every trophy, and one with no playtime.
+ */
+export const games: RecentGames = {
+ games: [
+ {
+ title: 'Moon Garden',
+ platforms: ['PS5'],
+ lastPlayed: '2026-10-21T00:05:00Z',
+ playtimeMinutes: 754,
+ trophies: { earned: 31, total: 52, percent: 59.6 },
+ },
+ { title: 'Tower Climb', platforms: ['Switch'], lastPlayed: '2026-10-20T12:30:00Z', playtimeMinutes: 1290 },
+ {
+ title: 'Harbour Lights',
+ platforms: ['PS4', 'PS5'],
+ lastPlayed: '2026-10-17T10:00:00Z',
+ playtimeMinutes: 2475,
+ trophies: { earned: 40, total: 40, percent: 100 },
+ },
+ {
+ title: 'Sky Rally',
+ platforms: ['PC'],
+ lastPlayed: '2026-10-12T09:00:00Z',
+ trophies: { earned: 3, total: 35, percent: 8.57 },
+ },
+ ],
+ searched: '2026-10-21T00:31:00Z',
+};
diff --git a/apps/web/e2e/games.spec.ts b/apps/web/e2e/games.spec.ts
new file mode 100644
index 0000000..10cf92f
--- /dev/null
+++ b/apps/web/e2e/games.spec.ts
@@ -0,0 +1,145 @@
+import type { Page } from '@playwright/test';
+
+import type { Snapshot } from '../src/games/games';
+import { bounds } from './bounds';
+import { chipLooks } from './chips';
+import { games, home } from './demo';
+import { expect, test } from './fixtures';
+import { group } from './groups';
+
+/** Answers the Games page's call for the games with the snapshot, or with the status. */
+async function answerGames(page: Page, answer: Snapshot | number) {
+ await page.route('/api/games/recent', (route) =>
+ typeof answer === 'number' ? route.fulfill({ status: answer }) : route.fulfill({ json: answer })
+ );
+}
+
+const played = (page: Page) => group(page, 'Recently played');
+const failure = 'list games: Get "https://www.exophase.com/public/player/1/games": 403 Forbidden';
+
+test.beforeEach(async ({ page }) => {
+ await page.route('/api/home', (route) => route.fulfill({ json: home }));
+});
+
+test('the Games page lists the games I played in the last 14 days', async ({ page }, testInfo) => {
+ await answerGames(page, games);
+ await page.goto('/games');
+
+ await expect(page).toHaveTitle('Games · Bakhyard');
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText('Games');
+ // The tab bar, the phone layout, has no tab for Games.
+ const [name, current] = testInfo.project.name === 'phone' ? ['More', 'true'] : ['Games', 'page'];
+ await expect(page.getByRole('link', { name, exact: true })).toHaveAttribute('aria-current', current);
+ await expect(played(page).getByText('4 games')).toBeVisible();
+ await expect(played(page).getByRole('listitem')).toHaveText([
+ 'Today Moon Garden PS5 31 of 52 trophies · 59% 12h 34m',
+ 'Yesterday Tower Climb Switch 21h 30m',
+ '17 Oct Harbour Lights PS4 · PS5 40 of 40 trophies · 100% 41h 15m',
+ '12 Oct Sky Rally PC 3 of 35 trophies · 8%',
+ ]);
+ await expect(played(page).getByText('Today')).toHaveAttribute('datetime', '2026-10-21T00:05:00Z');
+ await expect(page.getByRole('main').getByRole('link')).toHaveCount(0);
+ await expect(page.getByRole('main').locator('.chip')).toHaveCount(0);
+});
+
+test('the Games page marks the games stale when the latest fetch failed', async ({ page }) => {
+ await answerGames(page, { ...games, failure });
+ await page.goto('/games');
+
+ const line = played(page).getByText(/^The last search failed/);
+ await expect(line).toHaveText(`The last search failed: ${failure}. These games are from 21 October at 09:31.`);
+ await expect(line.locator('time')).toHaveAttribute('datetime', '2026-10-21T00:31:00Z');
+ await expect(played(page).getByRole('listitem')).toHaveCount(4);
+ const chip = played(page).locator('.chip');
+ await expect(chip).toHaveText('Stale');
+ expect(await chipLooks(chip)).toEqual(['outlined']);
+ // The chip follows the count, 10 px after it and centred on its line.
+ const note = played(page).getByText('4 games');
+ await expect(note).toHaveText('4 games Stale');
+ const count = await note.evaluate((line) => {
+ const text = document.createRange();
+ text.setStart(line.firstChild as Text, 0);
+ text.setEnd(line.firstChild as Text, '4 games'.length);
+ const { x, y, width, height } = text.getBoundingClientRect();
+ return { x, y, width, height };
+ });
+ const badge = await bounds(chip);
+ expect(badge.x - (count.x + count.width)).toBeCloseTo(10, 0);
+ // Within a pixel, since the text's box comes from its font's metrics, which the browser rounds.
+ expect(Math.abs(badge.y + badge.height / 2 - (count.y + count.height / 2))).toBeLessThan(1);
+ const [above, card] = [await bounds(line), await bounds(played(page).locator('.card'))];
+ expect(card.y - (above.y + above.height)).toBeCloseTo(20, 1);
+});
+
+test('before a fetch succeeds, the Games page says why it failed, with no list', async ({ page }) => {
+ await answerGames(page, { games: [], failure });
+ await page.goto('/games');
+
+ await expect(played(page).getByText(/^The last search failed/)).toHaveText(`The last search failed: ${failure}.`);
+ await expect(played(page).locator('.card')).toHaveCount(0);
+ await expect(played(page).locator('.chip')).toHaveCount(0);
+ await expect(played(page).getByText(/^\d+ games?$/)).toHaveCount(0);
+});
+
+test('the Games page says when I played no game in the last 14 days', async ({ page }) => {
+ await answerGames(page, { games: [], searched: games.searched });
+ await page.goto('/games');
+
+ await expect(played(page).getByText('No games played in the last 14 days.')).toBeVisible();
+ await expect(played(page).getByText(/^\d+ games?$/)).toHaveCount(0);
+});
+
+test('the Games page says when the games could not load', async ({ page }) => {
+ await answerGames(page, 500);
+ await page.goto('/games');
+
+ await expect(played(page).getByText("Couldn't load the games.")).toBeVisible();
+ await expect(played(page).getByRole('list')).toHaveCount(0);
+});
+
+test("the Games page says when Exophase isn't set up, with no section", async ({ page }) => {
+ await page.route('/api/games/recent', (route) => route.fulfill({ status: 404, body: 'Exophase is not set up\n' }));
+ await page.goto('/games');
+
+ await expect(page.getByRole('main').getByText("Exophase isn't set up.")).toBeVisible();
+ await expect(page.getByRole('main').getByRole('heading', { level: 2 })).toHaveCount(0);
+});
+
+test('the Games page loads the games again each time it shows', async ({ page }) => {
+ let snapshot = games;
+ await page.route('/api/games/recent', (route) => route.fulfill({ json: snapshot }));
+ await page.goto('/games');
+ await expect(played(page).getByText('4 games')).toBeVisible();
+
+ await page.getByRole('link', { name: 'Home', exact: true }).click();
+ await expect(page.getByRole('heading', { level: 1, name: 'Good morning, Alex.' })).toBeVisible();
+ // The games service finds a game played while the page is away.
+ const [first] = games.games;
+ snapshot = { ...games, games: [{ ...first, title: 'Night Ferry' }, ...games.games] };
+ await page
+ .getByRole('main')
+ .getByRole('link', { name: /Games Recent games and stats from Exophase/ })
+ .click();
+
+ await expect(played(page).getByText('5 games')).toBeVisible();
+ await expect(played(page).getByRole('listitem').first()).toContainText('Night Ferry');
+});
+
+test('a long title, platform or failure wraps on a narrow screen', async ({ page }) => {
+ await page.setViewportSize({ width: 320, height: 640 });
+ const game = {
+ title: `Moon ${'x'.repeat(200)}`,
+ platforms: [`Console ${'y'.repeat(60)}`, 'PS5'],
+ lastPlayed: '2026-10-21T00:05:00Z',
+ playtimeMinutes: 74_045,
+ trophies: { earned: 1234, total: 5678, percent: 21.7 },
+ };
+ await answerGames(page, { games: [game], searched: games.searched, failure: `${failure} ${'z'.repeat(200)}` });
+ await page.goto('/games');
+
+ await expect(played(page).getByRole('listitem')).toHaveText(
+ `Today ${game.title} ${game.platforms.join(' · ')} 1,234 of 5,678 trophies · 21% 1,234h 5m`
+ );
+ const scroll = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
+ expect(scroll).toBe(0);
+});
diff --git a/apps/web/e2e/page-status.spec.ts b/apps/web/e2e/page-status.spec.ts
index b1ddcb5..a7376c8 100644
--- a/apps/web/e2e/page-status.spec.ts
+++ b/apps/web/e2e/page-status.spec.ts
@@ -26,6 +26,14 @@ const lines: { name: string; path: string; says: string; answer: (page: Page) =>
await page.route('/api/time/report', (route) => route.fulfill({ status: 404 }));
},
},
+ {
+ name: 'Games',
+ path: '/games',
+ says: "Exophase isn't set up.",
+ answer: async (page) => {
+ await page.route('/api/games/recent', (route) => route.fulfill({ status: 404 }));
+ },
+ },
{
name: 'Nudge',
path: '/nudge',
diff --git a/apps/web/src/App.pages.test.ts b/apps/web/src/App.pages.test.ts
index d74ffec..8d723ba 100644
--- a/apps/web/src/App.pages.test.ts
+++ b/apps/web/src/App.pages.test.ts
@@ -15,6 +15,8 @@ function answer(url: string): Response {
case '/api/pulls/mine':
case '/api/pulls/reviews':
return Response.json({ pulls: [], searched });
+ case '/api/games/recent':
+ return Response.json({ games: [], searched });
case '/api/time/report':
return Response.json({
days: [{ date: '2026-10-20', loggedHours: 7.5, missing: false }],
@@ -39,13 +41,13 @@ afterEach(() => {
});
test('shows the page at the path it opens on', () => {
- history.replaceState(null, '', '/games');
+ history.replaceState(null, '', '/planner');
render(App);
- expect(screen.getByRole('heading', { level: 1, name: 'Games' })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 1, name: 'Planner' })).toBeInTheDocument();
expect(screen.getByText('Coming soon')).toBeInTheDocument();
- expect(document.title).toBe('Games · Bakhyard');
- expect(screen.getByRole('link', { name: 'Games', current: 'page' })).toBeInTheDocument();
+ expect(document.title).toBe('Planner · Bakhyard');
+ expect(screen.getByRole('link', { name: 'Planner', current: 'page' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'More', current: true })).toBeInTheDocument();
});
@@ -53,6 +55,7 @@ test.each([
['/todos', '1 todo on the list.', 'Todos · Bakhyard'],
['/pulls', 'No open pull requests.', 'Pull requests · Bakhyard'],
['/time', '7h 30m / 40h', 'Time · Bakhyard'],
+ ['/games', 'No games played in the last 14 days.', 'Games · Bakhyard'],
])('shows the page of the app at %s, with what it loads', async (path, loaded, title) => {
history.replaceState(null, '', path);
render(App);
diff --git a/apps/web/src/App.svelte b/apps/web/src/App.svelte
index 584707b..cd438c7 100644
--- a/apps/web/src/App.svelte
+++ b/apps/web/src/App.svelte
@@ -1,6 +1,7 @@
+
+
Exophase isn't set up.
+{:else} +
The last search failed: {failure}.
diff --git a/apps/web/src/shell/PolledSection.test.ts b/apps/web/src/shell/PolledSection.test.ts
index cc1728d..d413147 100644
--- a/apps/web/src/shell/PolledSection.test.ts
+++ b/apps/web/src/shell/PolledSection.test.ts
@@ -13,7 +13,7 @@ interface Snapshot extends Polls {
const searched = '2026-10-26T00:41:00Z';
const row = createRawSnippet<[string]>((item) => ({ render: () => `${item()}` }));
-function show(polled: Polled {note} {badge} {note}{`${title} `}{accent}
- {#if note}
+ {#if badge}
+
+