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: 0 additions & 33 deletions apps/web/e2e/demo.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
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 @@ -173,35 +172,3 @@ 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',
};
73 changes: 73 additions & 0 deletions apps/web/e2e/games-covers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { Page } from '@playwright/test';

import { bounds } from './bounds';
import { home } from './demo';
import { expect, test } from './fixtures';
import { answerCovers, answerGames, games } from './games';
import { group } from './groups';
import { colour } from './ink';

const played = (page: Page) => group(page, 'Recently played');

test.beforeEach(async ({ page }) => {
await page.route('/api/home', (route) => route.fulfill({ json: home }));
await answerCovers(page);
await answerGames(page, games);
});

test("each game's cover loads from IGDB into a framed blank, which a game without one keeps", async ({ page }) => {
const loaded: string[] = [];
page.on('requestfinished', (request) => {
if (request.url().startsWith('https://images.igdb.com/')) {
loaded.push(request.url());
}
});
await page.goto('/games');

const covers = played(page).locator('.cover');
await expect(covers).toHaveCount(4);
// The gateway's Content Security Policy lets them in: fixtures.ts fails the test on a violation.
const wanted = games.games.flatMap((game) => game.cover ?? []);
await expect.poll(() => loaded.toSorted()).toEqual(wanted.toSorted());
const [fill, rule] = [await colour(page, '--fill'), await colour(page, '--rule')];
const looks = await covers.evaluateAll((all) =>
all.map((cover) => {
const { backgroundColor, backgroundImage, backgroundPosition, backgroundSize, borderTopColor, borderTopWidth } =
getComputedStyle(cover);
return { backgroundColor, backgroundImage, backgroundPosition, backgroundSize, borderTopColor, borderTopWidth };
})
);
expect(looks).toEqual(
games.games.map((game) => ({
backgroundColor: fill,
backgroundImage: game.cover ? `url("${game.cover}")` : 'none',
backgroundPosition: '50% 50%',
backgroundSize: 'cover',
borderTopColor: rule,
borderTopWidth: '1px',
}))
);
});

test('the cover sits between the day and the title, or under the day on a phone', async ({ page }, testInfo) => {
await page.goto('/games');

const rows = played(page).locator('.list-row');
await expect(rows).toHaveCount(4);
for (const row of await rows.all()) {
const [box, label, cover, text] = await Promise.all(
[row, row.locator('.list-label'), row.locator('.cover'), row.locator('.list-text')].map(bounds)
);
expect([cover.width, cover.height]).toEqual([48, 68]);
expect(text.x - (cover.x + cover.width)).toBe(20);
if (testInfo.project.name === 'phone') {
// The day shares the first line with the playtime; the cover starts the second.
expect(cover.x - box.x).toBe(20);
expect(cover.y - (label.y + label.height)).toBeCloseTo(6, 1);
} else {
// After the row's 20 px of padding, the label's 120 px column and the 20 px gap.
expect(cover.x - box.x).toBe(160);
expect(cover.y + cover.height / 2).toBeCloseTo(box.y + box.height / 2, 1);
}
}
});
12 changes: 3 additions & 9 deletions apps/web/e2e/games.spec.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,18 @@
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 { home } from './demo';
import { expect, test } from './fixtures';
import { answerCovers, answerGames, games } from './games';
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 }));
await answerCovers(page);
});

test('the Games page lists the games I played in the last 14 days', async ({ page }, testInfo) => {
Expand Down
76 changes: 76 additions & 0 deletions apps/web/e2e/games.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { Page } from '@playwright/test';

import type { Snapshot } from '../src/games/games';

// Where IGDB serves the covers at the size that the games service gives (decision 305).
const igdb = 'https://images.igdb.com/igdb/image/upload/t_cover_big';

/** A made-up cover at IGDB's size, a disc on a ground, since no game's real art can be in the repo. */
function art(ground: string, disc: string): string {
return [
'<svg xmlns="http://www.w3.org/2000/svg" width="264" height="374">',
`<rect width="264" height="374" fill="${ground}"/><circle cx="132" cy="150" r="72" fill="${disc}"/>`,
'</svg>',
].join('');
}

const arts = new Map([
[`${igdb}/moon.jpg`, art('#22305a', '#f2d16b')],
[`${igdb}/tower.jpg`, art('#7d2a1e', '#f4b183')],
[`${igdb}/harbour.jpg`, art('#14504f', '#a5dbd0')],
]);

/**
* 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 and no cover.
*/
export const games: Snapshot = {
games: [
{
title: 'Moon Garden',
platforms: ['PS5'],
lastPlayed: '2026-10-21T00:05:00Z',
playtimeMinutes: 754,
trophies: { earned: 31, total: 52, percent: 59.6 },
cover: `${igdb}/moon.jpg`,
},
{
title: 'Tower Climb',
platforms: ['Switch'],
lastPlayed: '2026-10-20T12:30:00Z',
playtimeMinutes: 1290,
cover: `${igdb}/tower.jpg`,
},
{
title: 'Harbour Lights',
platforms: ['PS4', 'PS5'],
lastPlayed: '2026-10-17T10:00:00Z',
playtimeMinutes: 2475,
trophies: { earned: 40, total: 40, percent: 100 },
cover: `${igdb}/harbour.jpg`,
},
{
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',
};

/** Answers the Games page's call for the games with the snapshot, or with the status. */
export 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 })
);
}

/** Answers the browser's requests for the demo games' covers on IGDB with their made-up art, and any other with a 404. */
export async function answerCovers(page: Page) {
await page.route(`${igdb}/*`, (route) => {
const body = arts.get(route.request().url());
return body ? route.fulfill({ contentType: 'image/svg+xml', body }) : route.fulfill({ status: 404 });
});
}
15 changes: 10 additions & 5 deletions apps/web/e2e/ink.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import type { Page } from '@playwright/test';

// The colour --ink-muted gives text in the page's theme.
export function inkMuted(page: Page) {
return page.evaluate(() => {
/** The colour that the token, such as --fill, gives text in the page's theme. */
export function colour(page: Page, token: string) {
return page.evaluate((name) => {
const probe = document.body.appendChild(document.createElement('span'));
probe.style.color = 'var(--ink-muted)';
probe.style.color = `var(${name})`;
const { color } = getComputedStyle(probe);
probe.remove();
return color;
});
}, token);
}

// The colour --ink-muted gives text in the page's theme.
export function inkMuted(page: Page) {
return colour(page, '--ink-muted');
}
41 changes: 39 additions & 2 deletions apps/web/src/games/GamesPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@ 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';
import {
coverImage,
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();
Expand Down Expand Up @@ -32,8 +41,10 @@ onMount(() => {
markStale
>
{#snippet row(game)}
<div class="list-row">
<div class="list-row game">
<time class="list-label" datetime={game.lastPlayed}>{playedLabel(game.lastPlayed, now)}</time>
<!-- A background rather than an image: screen readers skip it, since the title names the game, and a cover that fails to load leaves the blank. -->
<span class="cover" style:background-image={coverImage(game.cover)}></span>
<span class="list-text">
<span class="list-title">{game.title}</span>
<span class="list-detail">{platformsLabel(game.platforms)}</span>
Expand All @@ -50,3 +61,29 @@ onMount(() => {
{/snippet}
</PolledSection>
{/if}

<style>
/* The cover gets a column between the day and the title (decision 308). */
.game {
grid-template-areas: "label cover text end";
grid-template-columns: 120px 48px 1fr auto;
}

/* IGDB's 264 by 374 px cover, scaled to the height of a title over two detail lines; a blank when the game has none. */
.cover {
grid-area: cover;
width: 48px;
height: 68px;
border: 1px solid var(--rule);
background: var(--fill) center / cover;
}

@media (max-width: 700px) {
.game {
grid-template-areas:
"label label end"
"cover text text";
grid-template-columns: 48px 1fr auto;
}
}
</style>
13 changes: 13 additions & 0 deletions apps/web/src/games/GamesPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function answer(response: () => Promise<Response>) {
// Saturday 31 October 2026, 09:41 in Tokyo, where the tests run.
const now = new Date('2026-10-31T09:41:00+09:00');
const searched = '2026-10-31T00:30:00Z';
const cover = 'https://images.igdb.com/igdb/image/upload/t_cover_big/co1abc.jpg';
const recent: Snapshot = {
games: [
{
Expand All @@ -25,6 +26,7 @@ const recent: Snapshot = {
lastPlayed: '2026-10-31T00:20:00Z',
playtimeMinutes: 750,
trophies: { earned: 12, total: 40, percent: 30 },
cover,
},
{ title: 'Tower Climb', platforms: ['Switch'], lastPlayed: '2026-10-30T11:00:00Z', playtimeMinutes: 45 },
{ title: 'Harbour Lights', platforms: ['PC'], lastPlayed: '2026-10-19T08:00:00Z' },
Expand Down Expand Up @@ -53,6 +55,17 @@ test('lists the games I played lately as it shows, a row each, none a link', asy
expect(fetchMock).toHaveBeenCalledExactlyOnceWith('/api/games/recent');
});

test("shows each game's cover as its blank's background, which screen readers skip", async () => {
answer(loaded(recent));
render(GamesPage, { props: { now } });

await screen.findByRole('list');
const blanks = rows().map((row) => row.querySelector<HTMLElement>('.cover'));
expect(blanks.map((blank) => blank?.style.backgroundImage)).toEqual([`url("${cover}")`, '', '']);
expect(blanks.every((blank) => blank?.childNodes.length === 0)).toBe(true);
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});

test('marks the games stale when the latest fetch failed, and says when they are from', async () => {
answer(loaded({ ...recent, failure: 'list games: 403 Forbidden' }));
render(GamesPage, { props: { now } });
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/games/games.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, expect, test, vi } from 'vitest';

import {
coverImage,
foundGames,
loadGames,
platformsLabel,
Expand Down Expand Up @@ -41,6 +42,18 @@ test.each<{ name: string; snapshot: Snapshot; want?: Snapshot['games'] }>([
expect(foundGames(snapshot)).toEqual(want);
});

test.each([
{ cover: undefined, want: undefined },
{
cover: 'https://images.igdb.com/igdb/image/upload/t_cover_big/co1abc.jpg',
want: 'url("https://images.igdb.com/igdb/image/upload/t_cover_big/co1abc.jpg")',
},
// A quote or a backslash would end or bend the CSS string, so they're escaped.
{ cover: 'https://example.com/a"b\\c.jpg', want: 'url("https://example.com/a\\"b\\\\c.jpg")' },
])('draws the cover $cover as the background image $want', ({ cover, want }) => {
expect(coverImage(cover)).toBe(want);
});

// The tests run in Tokyo, 9 hours ahead of UTC, where 31 October begins at 2026-10-30T15:00:00Z.
test.each([
{ lastPlayed: '2026-10-30T15:00:00Z', want: 'Today' },
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/games/games.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface Game {
playtimeMinutes?: number;
/** None for a game without trophies, such as Nintendo's. */
trophies?: Trophies;
/** The address of its cover on IGDB, at most 264 by 374 px; none when IGDB has none or isn't set up (decision 306). */
cover?: string;
}

/** The body of GET /api/games/recent: what the games service last found on Exophase (decision 303). */
Expand All @@ -41,6 +43,11 @@ export function foundGames(snapshot: Snapshot): Game[] | undefined {
return snapshot.searched === undefined ? undefined : snapshot.games;
}

/** A game's cover as a CSS background image, `url("…")`, or none when the game has none. */
export function coverImage(cover: string | undefined): string | undefined {
return cover === undefined ? undefined : `url(${JSON.stringify(cover)})`;
}

const dayMs = 86_400_000;
const dateFormat = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short' });

Expand Down
Loading
Loading