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
33 changes: 33 additions & 0 deletions apps/web/e2e/demo.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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',
};
145 changes: 145 additions & 0 deletions apps/web/e2e/games.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
8 changes: 8 additions & 0 deletions apps/web/e2e/page-status.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
11 changes: 7 additions & 4 deletions apps/web/src/App.pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
Expand All @@ -39,20 +41,21 @@ 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();
});

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);
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/App.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { tick } from 'svelte';

import GamesPage from './games/GamesPage.svelte';
import HomePage from './home/HomePage.svelte';
import { type Feed, pollHome } from './home/poll';
import { unavailableSources } from './home/sources';
Expand Down Expand Up @@ -76,6 +77,8 @@ $effect(() =>
<PullsPage />
{:else if page.kind === 'time'}
<TimePage />
{:else if page.kind === 'games'}
<GamesPage {now} />
{:else if page.kind === 'nudge'}
<NudgePage />
{:else if page.kind === 'app'}
Expand Down
52 changes: 52 additions & 0 deletions apps/web/src/games/GamesPage.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<script lang="ts">
import { onMount } from 'svelte';

import PageHead from '../shell/PageHead.svelte';
import PolledSection from '../shell/PolledSection.svelte';
import { foundGames, type Games, loadGames, platformsLabel, playedLabel, playtimeLabel, trophiesLabel } from './games';

// The games I played lately (decision 307), loaded each time the page shows; now tells which day is today.
let { now }: { now: Date } = $props();

let games = $state.raw<Games>({ kind: 'loading' });

onMount(() => {
void loadGames().then((loaded) => {
games = loaded;
});
});
</script>

<PageHead>Games</PageHead>
{#if games.kind === 'off'}
<p class="page-status">Exophase isn't set up.</p>
{:else}
<PolledSection
polled={games}
list={foundGames}
title="Recently"
accent="played"
noun="game"
empty="No games played in the last 14 days."
failed="Couldn't load the games."
markStale
>
{#snippet row(game)}
<div class="list-row">
<time class="list-label" datetime={game.lastPlayed}>{playedLabel(game.lastPlayed, now)}</time>
<span class="list-text">
<span class="list-title">{game.title}</span>
<span class="list-detail">{platformsLabel(game.platforms)}</span>
{#if game.trophies}
<span class="list-detail">{trophiesLabel(game.trophies)}</span>
{/if}
</span>
<span class="list-end">
{#if game.playtimeMinutes}
{playtimeLabel(game.playtimeMinutes)}
{/if}
</span>
</div>
{/snippet}
</PolledSection>
{/if}
Loading
Loading