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 @@ + +Games +{#if games.kind === 'off'} +

Exophase isn't set up.

+{:else} + + {#snippet row(game)} +
+ + + {game.title} + {platformsLabel(game.platforms)} + {#if game.trophies} + {trophiesLabel(game.trophies)} + {/if} + + + {#if game.playtimeMinutes} + {playtimeLabel(game.playtimeMinutes)} + {/if} + +
+ {/snippet} +
+{/if} diff --git a/apps/web/src/games/GamesPage.test.ts b/apps/web/src/games/GamesPage.test.ts new file mode 100644 index 0000000..ba1fa5f --- /dev/null +++ b/apps/web/src/games/GamesPage.test.ts @@ -0,0 +1,110 @@ +import { render, screen, within } from '@testing-library/svelte'; +import { afterEach, expect, test, vi } from 'vitest'; + +import GamesPage from './GamesPage.svelte'; +import type { Snapshot } from './games'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function answer(response: () => Promise) { + const fetchMock = vi.fn(response); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +// 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 recent: Snapshot = { + games: [ + { + title: 'Moon Garden', + platforms: ['PS4', 'PS5'], + lastPlayed: '2026-10-31T00:20:00Z', + playtimeMinutes: 750, + trophies: { earned: 12, total: 40, percent: 30 }, + }, + { title: 'Tower Climb', platforms: ['Switch'], lastPlayed: '2026-10-30T11:00:00Z', playtimeMinutes: 45 }, + { title: 'Harbour Lights', platforms: ['PC'], lastPlayed: '2026-10-19T08:00:00Z' }, + ], + searched, +}; + +const loaded = (snapshot: Snapshot) => async () => Response.json(snapshot); +const rows = () => within(screen.getByRole('list')).getAllByRole('listitem'); + +test('lists the games I played lately as it shows, a row each, none a link', async () => { + const fetchMock = answer(loaded(recent)); + render(GamesPage, { props: { now } }); + + expect(screen.getByRole('heading', { level: 1, name: 'Games' })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { level: 2, name: 'Recently played' })).toBeInTheDocument(); + expect(screen.getByText('3 games')).toBeInTheDocument(); + expect(rows().map((row) => row.textContent?.replaceAll(/\s+/g, ' ').trim())).toEqual([ + 'Today Moon Garden PS4 · PS5 12 of 40 trophies · 30% 12h 30m', + 'Yesterday Tower Climb Switch 45m', + '19 Oct Harbour Lights PC', + ]); + expect(within(rows()[0]).getByText('Today')).toHaveAttribute('datetime', '2026-10-31T00:20:00Z'); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(screen.queryByText('Stale')).not.toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledExactlyOnceWith('/api/games/recent'); +}); + +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 } }); + + expect(await screen.findByText('Stale')).toHaveClass('chip'); + expect(screen.getByText('3 games')).toContainElement(screen.getByText('Stale')); + expect(screen.getByText(/^The last search failed/)).toHaveTextContent( + 'The last search failed: list games: 403 Forbidden. These games are from 31 October at 09:30.' + ); + expect(rows()).toHaveLength(3); +}); + +test('says why the first fetch failed, with no list, count or chip', async () => { + answer(loaded({ games: [], failure: 'list games: 403 Forbidden' })); + render(GamesPage, { props: { now } }); + + expect(await screen.findByText(/^The last search failed/)).toHaveTextContent( + /^The last search failed: list games: 403 Forbidden\.$/ + ); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + expect(screen.queryByText('No games played in the last 14 days.')).not.toBeInTheDocument(); + expect(screen.queryByText('Stale')).not.toBeInTheDocument(); +}); + +test('says when I played no game in the last 14 days', async () => { + answer(loaded({ games: [], searched })); + render(GamesPage, { props: { now } }); + + expect(await screen.findByText('No games played in the last 14 days.')).toBeInTheDocument(); + expect(screen.queryByText(/^\d+ games?$/)).not.toBeInTheDocument(); +}); + +test("says when Exophase isn't set up, with no section", async () => { + answer(async () => new Response('Exophase is not set up\n', { status: 404 })); + render(GamesPage, { props: { now } }); + + expect(await screen.findByText("Exophase isn't set up.")).toHaveClass('page-status'); + expect(screen.queryByRole('heading', { level: 2 })).not.toBeInTheDocument(); +}); + +test("says when the games couldn't load", async () => { + answer(async () => new Response('Bad Gateway\n', { status: 502 })); + render(GamesPage, { props: { now } }); + + expect(await screen.findByText("Couldn't load the games.")).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 2, name: 'Recently played' })).toBeInTheDocument(); +}); + +test('shows the head alone, with no summary, while the games load', () => { + answer(() => Promise.withResolvers().promise); + const { container } = render(GamesPage, { props: { now } }); + + expect(screen.getByRole('heading', { level: 1, name: 'Games' })).toBeInTheDocument(); + expect(container).toHaveTextContent(/^Games$/); +}); diff --git a/apps/web/src/games/games.test.ts b/apps/web/src/games/games.test.ts new file mode 100644 index 0000000..e48100e --- /dev/null +++ b/apps/web/src/games/games.test.ts @@ -0,0 +1,91 @@ +import { afterEach, expect, test, vi } from 'vitest'; + +import { + foundGames, + loadGames, + platformsLabel, + playedLabel, + playtimeLabel, + type Snapshot, + trophiesLabel, +} from './games'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const game = { title: 'Moon Garden', platforms: ['PS5'], lastPlayed: '2026-10-30T11:00:00Z' }; + +test('loads the games I played lately from the games service', async () => { + const snapshot: Snapshot = { games: [game], searched: '2026-10-30T12:00:00Z' }; + const fetchMock = vi.fn().mockResolvedValue(Response.json(snapshot)); + vi.stubGlobal('fetch', fetchMock); + + expect(await loadGames()).toEqual({ kind: 'loaded', snapshot }); + expect(fetchMock).toHaveBeenCalledExactlyOnceWith('/api/games/recent'); +}); + +test.each<{ name: string; snapshot: Snapshot; want?: Snapshot['games'] }>([ + { + name: 'the games of a fetch that succeeded', + snapshot: { games: [game], searched: '2026-10-30T12:00:00Z' }, + want: [game], + }, + { + name: 'none of a fetch that succeeded but found none', + snapshot: { games: [], searched: '2026-10-30T12:00:00Z', failure: 'list games: 403 Forbidden' }, + want: [], + }, + { name: 'nothing before a fetch has succeeded', snapshot: { games: [], failure: 'list games: 403 Forbidden' } }, +])('finds $name', ({ snapshot, want }) => { + expect(foundGames(snapshot)).toEqual(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' }, + { lastPlayed: '2026-10-31T00:40:00Z', want: 'Today' }, + { lastPlayed: '2026-10-30T14:59:59Z', want: 'Yesterday' }, + { lastPlayed: '2026-10-29T15:00:00Z', want: 'Yesterday' }, + { lastPlayed: '2026-10-29T14:59:59Z', want: '29 Oct' }, + { lastPlayed: '2026-10-17T03:00:00Z', want: '17 Oct' }, + { lastPlayed: '2026-11-01T01:00:00Z', want: '1 Nov' }, +])('says I last played a game at $lastPlayed on "$want"', ({ lastPlayed, want }) => { + expect(playedLabel(lastPlayed, new Date('2026-10-31T09:41:00+09:00'))).toBe(want); +}); + +test('names the day across the turn of a month and a year', () => { + expect(playedLabel('2026-12-31T03:00:00Z', new Date('2027-01-01T09:00:00+09:00'))).toBe('Yesterday'); + expect(playedLabel('2026-12-30T03:00:00Z', new Date('2027-01-01T09:00:00+09:00'))).toBe('30 Dec'); + // At 05:00 on New Year's Day in Tokyo, while UTC is still on the old year's last day. + expect(playedLabel('2026-12-31T20:00:00Z', new Date('2027-01-01T10:00:00+09:00'))).toBe('Today'); +}); + +test.each([ + { platforms: ['PS5'], want: 'PS5' }, + { platforms: ['PS4', 'PS5'], want: 'PS4 · PS5' }, + { platforms: [], want: '' }, +])('lists the platforms $platforms as "$want"', ({ platforms, want }) => { + expect(platformsLabel(platforms)).toBe(want); +}); + +test.each([ + { trophies: { earned: 12, total: 40, percent: 30 }, want: '12 of 40 trophies · 30%' }, + { trophies: { earned: 0, total: 1, percent: 0 }, want: '0 of 1 trophy · 0%' }, + { trophies: { earned: 1, total: 3, percent: 33.33 }, want: '1 of 3 trophies · 33%' }, + { trophies: { earned: 49, total: 50, percent: 99.9 }, want: '49 of 50 trophies · 99%' }, + { trophies: { earned: 50, total: 50, percent: 100 }, want: '50 of 50 trophies · 100%' }, + { trophies: { earned: 1234, total: 5678, percent: 21.7 }, want: '1,234 of 5,678 trophies · 21%' }, +])('counts the trophies $trophies as "$want"', ({ trophies, want }) => { + expect(trophiesLabel(trophies)).toBe(want); +}); + +test.each([ + { minutes: 750, want: '12h 30m' }, + { minutes: 45, want: '45m' }, + { minutes: 180, want: '3h' }, + { minutes: 1, want: '1m' }, + { minutes: 74_045, want: '1,234h 5m' }, +])('says $minutes minutes of play are "$want"', ({ minutes, want }) => { + expect(playtimeLabel(minutes)).toBe(want); +}); diff --git a/apps/web/src/games/games.ts b/apps/web/src/games/games.ts new file mode 100644 index 0000000..9d4e387 --- /dev/null +++ b/apps/web/src/games/games.ts @@ -0,0 +1,81 @@ +import { loadSnapshot, type Polled, type Polls } from '../shell/snapshot'; + +/** The trophies or achievements I earned in a game, out of its total. */ +interface Trophies { + earned: number; + total: number; + /** How far I got through them, as Exophase reckons it. */ + percent: number; +} + +/** A game I played lately, as the games service lists it (decisions 301 and 303). */ +export interface Game { + /** Its name on Exophase. */ + title: string; + /** The platforms that Exophase lists for it, such as PS5 or Switch. */ + platforms: string[]; + /** When I last played it, in UTC. */ + lastPlayed: string; + /** How long I've played it; none when Exophase doesn't know. */ + playtimeMinutes?: number; + /** None for a game without trophies, such as Nintendo's. */ + trophies?: Trophies; +} + +/** The body of GET /api/games/recent: what the games service last found on Exophase (decision 303). */ +export interface Snapshot extends Polls { + /** The games I played in the last 14 days, the last played first; empty, not missing, before a fetch succeeds. */ + games: Game[]; +} + +/** The Games page's list: loading, off when Exophase isn't set up, failed to load, or loaded. */ +export type Games = Polled; + +/** Loads the games I played lately; never fails, since a failed load is one of the outcomes. */ +export function loadGames(): Promise { + return loadSnapshot('/api/games/recent'); +} + +/** The snapshot's games, which only a fetch that succeeded can have found; none before one has. */ +export function foundGames(snapshot: Snapshot): Game[] | undefined { + return snapshot.searched === undefined ? undefined : snapshot.games; +} + +const dayMs = 86_400_000; +const dateFormat = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short' }); + +// The date's day in the device's time zone, as a count of days, so a difference of two is whole even across summer time. +function dayNumber(date: Date): number { + return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / dayMs; +} + +/** The day I last played a game, in the device's time zone: "Today", "Yesterday" or "19 Oct". */ +export function playedLabel(lastPlayed: string, now: Date): string { + const played = new Date(lastPlayed); + const daysAgo = dayNumber(now) - dayNumber(played); + if (daysAgo === 0) { + return 'Today'; + } + return daysAgo === 1 ? 'Yesterday' : dateFormat.format(played); +} + +/** The platforms that Exophase lists for a game: "PS4 · PS5". */ +export function platformsLabel(platforms: readonly string[]): string { + return platforms.join(' · '); +} + +const countFormat = new Intl.NumberFormat('en-GB'); + +/** The trophies I earned in a game, with the percentage rounded down, so only all of them make 100%. */ +export function trophiesLabel({ earned, total, percent }: Trophies): string { + const noun = total === 1 ? 'trophy' : 'trophies'; + return `${countFormat.format(earned)} of ${countFormat.format(total)} ${noun} · ${Math.floor(percent)}%`; +} + +const minutesPerHour = 60; +const duration = new Intl.DurationFormat('en-GB', { style: 'narrow' }); + +/** How long I've played a game: "12h 30m", "45m" or "3h". */ +export function playtimeLabel(minutes: number): string { + return duration.format({ hours: Math.floor(minutes / minutesPerHour), minutes: minutes % minutesPerHour }); +} diff --git a/apps/web/src/pages/MorePage.test.ts b/apps/web/src/pages/MorePage.test.ts index 55c0bf9..ce7b767 100644 --- a/apps/web/src/pages/MorePage.test.ts +++ b/apps/web/src/pages/MorePage.test.ts @@ -38,7 +38,7 @@ test('lists the apps without a tab of their own as cards, with their counts', () expect(cardTexts()).toEqual([ '04 2 items Time Hours logged on the Jira tickets', '05 1 item GitHub PRs, reviews and issues across owners', - '06 0 items Games Backloggd and Exophase', + '06 0 items Games Recent games and stats from Exophase', '07 0 items Planner Copilot plans saved to the vault', '08 0 items Nudge Questions from your agents, answered on your phone', ]); @@ -50,7 +50,7 @@ test('shows the cards without counts before the Home loads', () => { expect(cardTexts()).toEqual([ '04 Time Hours logged on the Jira tickets', '05 GitHub PRs, reviews and issues across owners', - '06 Games Backloggd and Exophase', + '06 Games Recent games and stats from Exophase', '07 Planner Copilot plans saved to the vault', '08 Nudge Questions from your agents, answered on your phone', ]); diff --git a/apps/web/src/shell/PolledSection.svelte b/apps/web/src/shell/PolledSection.svelte index 3da780c..dc4d556 100644 --- a/apps/web/src/shell/PolledSection.svelte +++ b/apps/web/src/shell/PolledSection.svelte @@ -17,6 +17,7 @@ let { noun, empty, failed, + markStale = false, row, }: { polled: Polled; @@ -28,6 +29,8 @@ let { noun: string; empty: string; failed: string; + /** Whether a list from an earlier search, kept after the latest failed, gets a Stale chip after its count. */ + markStale?: boolean; row: Snippet<[T]>; } = $props(); @@ -35,7 +38,8 @@ let { {#if polled.kind === 'loaded'} {@const { searched, failure } = polled.snapshot} {@const items = list(polled.snapshot)} -
+ {@const stale = markStale && failure && items && searched} +
{#if failure}

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) { +function show(polled: Polled, markStale?: boolean) { return render(PolledSection, { props: { polled, @@ -23,6 +23,7 @@ function show(polled: Polled) { noun: 'issue', empty: 'No open issues.', failed: "Couldn't load the issues.", + markStale, row, }, }); @@ -57,3 +58,20 @@ test.each([ expect(screen.getByText(/^The last search failed/)).toHaveTextContent(/^The last search failed: search: 502\.$/); expect(document.querySelector('time')).not.toBeInTheDocument(); }); + +test('marks a list from an earlier search with a Stale chip after its count, when asked to', () => { + show({ kind: 'loaded', snapshot: { items: ['First'], searched, failure: 'search: 502' } }, true); + + expect(screen.getByText('1 issue')).toContainElement(screen.getByText('Stale')); +}); + +test.each<{ snapshot: Snapshot; markStale?: boolean }>([ + { snapshot: { items: ['First'], searched, failure: 'search: 502' } }, + { snapshot: { items: ['First'], searched }, markStale: true }, + { snapshot: { searched, failure: 'search: 502' }, markStale: true }, + { snapshot: { items: ['First'], failure: 'search: 502' }, markStale: true }, +])('marks no list stale but one from an earlier search, when asked to: %o', ({ snapshot, markStale }) => { + show({ kind: 'loaded', snapshot }, markStale); + + expect(screen.queryByText('Stale')).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/shell/Section.svelte b/apps/web/src/shell/Section.svelte index 24f76cf..1b8a378 100644 --- a/apps/web/src/shell/Section.svelte +++ b/apps/web/src/shell/Section.svelte @@ -8,20 +8,25 @@ interface Link { label: string; } +// A badge is a chip after the note, such as Stale. let { title, accent, note, + badge, link, children, -}: { title: string; accent: string; note?: string; link?: Link; children: Snippet } = $props(); +}: { title: string; accent: string; note?: string; badge?: string; link?: Link; children: Snippet } = $props();

{`${title} `}{accent}

- {#if note} + {#if badge} + +

{note} {badge}

+ {:else if note}

{note}

{/if} {#if link} @@ -69,6 +74,12 @@ em { text-transform: uppercase; } +.badged { + display: flex; + align-items: center; + gap: 10px; +} + /* The underline is a border, so it runs under the arrow too. */ .link { display: inline-flex; diff --git a/apps/web/src/shell/Section.test.ts b/apps/web/src/shell/Section.test.ts index af06cba..ba5b49d 100644 --- a/apps/web/src/shell/Section.test.ts +++ b/apps/web/src/shell/Section.test.ts @@ -30,3 +30,16 @@ test('shows a text link, with an arrow hidden from screen readers', () => { expect(link).toHaveAttribute('href', '/time'); expect(link.querySelector('svg')).toHaveAttribute('aria-hidden', 'true'); }); + +test('shows a badge as a chip after the note', () => { + render(Section, { props: { title: 'Recently', accent: 'played', note: '3 games', badge: 'Stale', children } }); + + expect(screen.getByRole('paragraph')).toHaveTextContent(/^3 games Stale$/); + expect(screen.getByText('Stale')).toHaveClass('chip'); +}); + +test('shows a badge without a note', () => { + render(Section, { props: { title: 'Recently', accent: 'played', badge: 'New', children } }); + + expect(screen.getByRole('paragraph')).toHaveTextContent(/^New$/); +}); diff --git a/apps/web/src/shell/apps.ts b/apps/web/src/shell/apps.ts index 64ab6c6..3bcbe27 100644 --- a/apps/web/src/shell/apps.ts +++ b/apps/web/src/shell/apps.ts @@ -17,7 +17,7 @@ const catalogue: readonly Omit[] = [ { id: 'pulls', label: 'PRs', name: 'Pull requests', description: 'Bitbucket PRs and reviewer nudges' }, { id: 'time', label: 'Time', name: 'Time', description: 'Hours logged on the Jira tickets' }, { id: 'github', label: 'GitHub', name: 'GitHub', description: 'PRs, reviews and issues across owners' }, - { id: 'games', label: 'Games', name: 'Games', description: 'Backloggd and Exophase' }, + { id: 'games', label: 'Games', name: 'Games', description: 'Recent games and stats from Exophase' }, { id: 'planner', label: 'Planner', name: 'Planner', description: 'Copilot plans saved to the vault' }, { id: 'nudge', label: 'Nudge', name: 'Nudge', description: 'Questions from your agents, answered on your phone' }, ]; diff --git a/apps/web/src/shell/page.test.ts b/apps/web/src/shell/page.test.ts index e2051f5..53bc416 100644 --- a/apps/web/src/shell/page.test.ts +++ b/apps/web/src/shell/page.test.ts @@ -38,7 +38,10 @@ test.each([ { path: '/time/2026-10-21', want: 'time' }, { path: '/time-archive', want: 'missing' }, { path: '/github', want: 'app github' }, - { path: '/games/zelda', want: 'app games' }, + { path: '/games', want: 'games' }, + { path: '/games/', want: 'games' }, + { path: '/games/zelda', want: 'games' }, + { path: '/games-archive', want: 'missing' }, { path: '/planner', want: 'app planner' }, { path: '/nudge', want: 'nudge' }, { path: '/nudge/', want: 'nudge' }, diff --git a/apps/web/src/shell/page.ts b/apps/web/src/shell/page.ts index b3e3ae8..0d4065b 100644 --- a/apps/web/src/shell/page.ts +++ b/apps/web/src/shell/page.ts @@ -10,6 +10,7 @@ export type Page = | { kind: 'todos' } | { kind: 'pulls' } | { kind: 'time' } + | { kind: 'games' } | { kind: 'nudge' } | { kind: 'app'; app: App } | { kind: 'more' } @@ -19,12 +20,13 @@ const notesHref = appHref('notes'); const todosHref = appHref('todos'); const pullsHref = appHref('pulls'); const timeHref = appHref('time'); +const gamesHref = appHref('games'); const nudgeHref = appHref('nudge'); /** * The page at path. The Notes app's path shows the notes, and the paths under it their notes; the Todos app's paths - * show the todos, the PRs app's my PRs, the Time app's the time report, and the Nudge app's the agents' questions; - * another app's paths show its page, as the nav marks it. + * show the todos, the PRs app's my PRs, the Time app's the time report, the Games app's the games I played lately, + * and the Nudge app's the agents' questions; another app's paths show its page, as the nav marks it. */ export function pageAt(path: string): Page { if (path === '/') { @@ -45,6 +47,9 @@ export function pageAt(path: string): Page { if (isCurrent(timeHref, path)) { return { kind: 'time' }; } + if (isCurrent(gamesHref, path)) { + return { kind: 'games' }; + } if (isCurrent(nudgeHref, path)) { return { kind: 'nudge' }; } @@ -81,6 +86,8 @@ export function pageTitle(page: Page): string { return `Pull requests · ${siteName}`; case 'time': return `Time · ${siteName}`; + case 'games': + return `Games · ${siteName}`; case 'nudge': return `Nudge · ${siteName}`; case 'app': diff --git a/docs/plan.md b/docs/plan.md index 83b9ced..3478cdf 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -199,6 +199,7 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh | `twitch-token` | The `games` service gets a Twitch app token for IGDB with the client credentials grant, keeps it in memory until 9 tenths of its life, asks Twitch one request at a time, and forgets a token that IGDB refuses. The fifth of `games-exophase`'s eleven steps (decision 304) | Done (#181) | | `igdb-search` | The `games` service's IGDB client finds a title's cover: the top result of a search filtered to main games, expansions, remakes, remasters, expanded games and ports, or a game named exactly the title when the top result has another name; it asks one request at a time, at least 250 ms apart, and with a new token once when IGDB refuses one. The sixth of `games-exophase`'s eleven steps (decision 305) | Done (#183) | | `games-covers` | Each game in the `games` service's answer carries its IGDB cover when `BAKHYARD_TWITCH_CLIENT_ID` and the Keychain's `twitch-client-secret` are set: each title's cover, or that IGDB has none, is kept a week, the first failure stops the asking until the next fetch, and a missing cover never fails the list. The seventh of `games-exophase`'s eleven steps (decision 306) | Done (#186) | +| `games-page` | The Games page lists the games I played in the last 14 days from `GET /api/games/recent`, each time it shows: for each, the day I last played it, its title, its platforms, its trophies and its playtime. A list kept from an earlier fetch, after the latest failed, gets a Stale chip after its count, and the page says that Exophase isn't set up until the gateway forwards to `games`. The Games card says "Recent games and stats from Exophase". The tenth of `games-exophase`'s eleven steps (decision 307) | Done (#190) | | `vault-edit-sync` | Editing the vault from Bakhyard (§8.2 Phase 6), by the vault session in `../bakhyard-vault/`, which adds its steps' rows under this one and then does `copilot-planner` (decision 195) | In progress | | `vault-plan` | The vault session's first steps: where a save goes waits on the user's answer to §12's question on how edits get back to the Mac, so the version that a save checks and the editor come first; a note's version is the SHA-256 of its bytes, sent as its `ETag`, and a save is a `PUT` that sends it back in `If-Match` (decision 400) | Done (#167) | | `note-etag` | notes answers each note with its version as a strong `ETag`, the SHA-256 of its bytes, and a matching `If-None-Match` with a 304 (decision 400) | Done (#169) | @@ -1007,12 +1008,13 @@ Cards are solid `--surface`, so they sit on the grain like paper on a desk. Thes - My open Jira tickets have a section of their own after the priority groups, or after "Couldn't load the todos.", once the todos show or fail to load (built in `jira-list`, decision 124): "Jira *tickets*", with the count as the note, "1 ticket" or "3 tickets". Its card holds a list row per ticket, in the service's order, that links to the ticket in Jira: the key as the row's mono label, the summary as its title, and the status at its end in a chip, filled for the In Progress category and outlined for the others, at most 160 px wide and wrapping inside. With no ticket, the card says "No open tickets." in `--ink-muted` and the note is left out. When the last search failed, a muted line 20 px above the card says "The last search failed: .", then "These tickets are from 21 October at 09:38." when it has tickets from an earlier search. Any other failure to load: "Couldn't load the tickets." under the head. Without Jira, no section. - Pull requests, built in `pulls-page` (decision 149): "Pull requests", with no summary. Under the head, my open PRs sit in a section, "Your *PRs*", with the count as the note, "1 PR" or "3 PRs". Its card holds a list row per PR, in the service's order, the latest updated first, that links to the PR on Bitbucket: the number as the row's mono label ("#128"), the title as its title, the repository's full name ("acme/web") under it as its detail, and, on a draft, a Draft chip at its end, outlined as the Jira section's chips that aren't In Progress. Under the repository, built in `approvals-page` (decision 174), a second detail line says where the PR stands with its reviewers, a group for each verdict that someone has given, the most urgent first, split by " · ": "Changes requested by Noor Aziz · Waiting for Tom Berg and Leo Hart · Approved by Mia Chen"; with no reviewers, "No reviewers". A PR without a link shows in a row that isn't a link. With no PR, the card says "No open pull requests." in `--ink-muted` and the note is left out. When the last search failed, a muted line 20 px above the card says "The last search failed: .", then "These PRs are from 21 October at 09:38." when it has PRs from an earlier search. Any other failure to load: "Couldn't load the pull requests." under the section's head. Under it, built in `reviews-page` (decision 172), the PRs that wait for my review sit in a section of their own, "Review *requests*", 64 px below my PRs' card (48 px on a phone), with the same rows, without the review line, and the same note and failed-search lines. With none, its card says "No pull requests to review."; any other failure to load: "Couldn't load the review requests." under its head. Each section shows once its own list loads, so neither waits for the other, and nothing shows for a list while it loads. Without Bitbucket, when both lists answer 404, "Bitbucket isn't set up." in `--ink-muted` under the page head, with no section; when only one does, the page leaves its section out. - Time, built in `time-page` (decision 184) and `fill-day` (decision 194): "Time", with no summary. Under the head, a form fills a day (below). Under it, this week's hours sit in a section, "This *week*", on the card that the Home's "This week" shows (below), without its link. Under it, 64 px below the card (48 px on a phone), the last 7 days sit in a section, "Last 7 *days*", with the missing days' count as the note, "1 day missing" or "3 days missing", left out when none is. Its card holds a list row per day, in the report's order, the earliest first, and none is a link: the date as the row's mono label ("15 Oct"), the weekday as its title ("Thursday"), or "Today" for the last day, and at its end the hours logged ("7h 30m", "0h"), in the end's mono capitals, in `--ink` when any are logged and `--ink-muted` when none are. A weekday before today with no hours has a Missing chip before them, filled as a late item's chip. Nothing shows under the head while the report loads, which it does each time the page shows. When the last search failed, a muted line under the head says "The last search failed: .", then "These hours are from 21 October at 09:38." when it has days from an earlier search, 30 px above the form or the first section (24 px on a phone), as the Todos page's form; with no days yet, the line alone. Without Jira, "Jira isn't set up." in `--ink-muted` under the head, with no section; any other failure to load: "Couldn't load the time report.". The form shows once the report's days and my open tickets have loaded, 30 px above the first section (24 px on a phone): three selects in the ticket select's look, with our chevron, Day ("Friday 16 Oct", or "Today"), Ticket ("Pick a ticket", then "WEB-9 · Fix the date picker in Safari") and Hours ("8h"), then a Log button, in a row that wraps with its lines 12 px apart, and the status line under it ("Logging…", "Logged", the service's reason, "Log failed"). The day select is as wide as its longest day, the ticket select takes most of the room to spare, and the hours and Log wrap together: one line from about 720 px wide, Day and Ticket over Hours and Log down to about 500 px, a line each for Day and Ticket below that; the page never scrolls sideways, down to 320 px wide. Log opens a modal dialog in the delete dialog's look: "Log these hours?", then "8h on WEB-9 · Fix the date picker in Safari, from 09:00 on Friday 16 Oct.", with Cancel and the primary Log. In the form's place, in `--ink-muted`: "Couldn't load your Jira tickets." or "No open Jira tickets to log hours on.". +- Games, built in `games-page` (decision 307): "Games", with no summary. Under the head, the games I played in the last 14 days sit in a section, "Recently *played*", with the count as the note, "1 game" or "3 games". Its card holds a list row per game, in the service's order, the last played first, and none is a link: the day I last played it as the row's mono label, in the device's time zone ("Today", "Yesterday", "19 Oct"), the title as its title, the platforms under it as its detail ("PS4 · PS5"), then, when the game has trophies, a second detail line ("31 of 52 trophies · 59%", "1 of 1 trophy · 100%"), with the percentage rounded down, so only all of them make 100%, and at its end the playtime ("12h 34m", "45m", "3h") in the end's mono capitals, left out when Exophase doesn't know it. Trophies are "trophies" on every platform, since the service doesn't say which call them achievements. With no game, the card says "No games played in the last 14 days." in `--ink-muted` and the note is left out. When the last search failed, a muted line 20 px above the card says "The last search failed: .", then "These games are from 21 October at 09:38." when it has games from an earlier search, and a Stale chip, outlined, follows the count in the section's head (decision 67); before any search has succeeded, the line alone, with no card, count or chip. Nothing shows under the head while the games load, which they do each time the page shows. Without Exophase, when the games answer 404, "Exophase isn't set up." in `--ink-muted` under the page head, with no section; any other failure to load: "Couldn't load the games." under the section's head. A long title, platform or failure wraps, and the page never scrolls sideways, down to 320 px wide. - More: "More", with the summary "The apps without a tab of their own.", then the cards of Time, GitHub, Games and Planner as in "Your apps", with their numbers (04 to 07) and counts. The cards' names are `h2`s, under the page's `h1`. The grid ends 64 px above the page's end (48 on phones), as a section does. - Any other path: "Page not found", with "There's no page at /elsewhere." as the summary. **Sign-in screen**, built in `signin-screen` (decision 86): without a session, every path shows the top bar alone over a page head, "Signed out", with "Sign in with your Google account to see your day." as the summary, and under it a primary button, "Sign in", which links to `/auth/google/login`. No header and no tab bar. The document's title is "Signed out · Bakhyard". -**Section head**: a 1 px `--ink` rule on top with 13 px of space under it (ref), then the title on the left and a mono note or a text link on the right, aligned to the bottom, with 30 px below (24 on phones) (ref). A note that doesn't fit beside the title wraps under it. Each section has 64 px of space below it (48 on phones). +**Section head**: a 1 px `--ink` rule on top with 13 px of space under it (ref), then the title on the left and a mono note or a text link on the right, aligned to the bottom, with 30 px below (24 on phones) (ref). A note that doesn't fit beside the title wraps under it, and can end with a chip, 10 px after it and centred on its line, such as the Games page's Stale (decision 307). Each section has 64 px of space below it (48 on phones). **Status line**: a line that takes a page's sections' place, such as "Bitbucket isn't set up." or "Couldn't load the todos.", in body text and `--ink-muted` under the page head, with a section's space below it, and a long word breaks rather than widen the page (decision 206). @@ -1291,7 +1293,7 @@ Every item is pending unless it says done. The service an item lands in is in br - `games-covers`: each game in the answer carries its IGDB cover; each title is matched once and the match kept for a week (decision 306). Done (#186). - `homelab-games`: in the homelab repo, `games` runs on the laptop with its settings and secret, and the gateway's peers name it. - `games-gateway`: the gateway forwards `/api/games` to `games`. - - `games-page`: the Games page lists the games, and marks them stale when the latest fetch failed (decision 67); the Games card's description in the app becomes §7's. + - `games-page`: the Games page lists the games, and marks them stale when the latest fetch failed (decisions 67, 307); the Games card's description in the app becomes §7's. Done (#190). - `games-page-covers`: the Games page shows each game's cover. **Phase 6: vault edits and Copilot** @@ -1839,6 +1841,14 @@ Decisions 300 to 399 are the `games-exophase` session's. - **A known limit**: a title whose search alone fails, rather than IGDB as a whole, stops the asking at the same place at each fetch, so the titles after it get no cover until it leaves the list; the log names it. - **Rejected**: the cover in `exophase.Game`, which would tie the Exophase client to IGDB; failing the fetch for a missing cover, which would keep the last list, stale, for want of a picture; asking on past a failure, which would add up to 10 seconds a title while IGDB or Twitch is down; finding the covers after answering, which would show them only with the next fetch, an hour later; keeping errors, which would hide a recovered IGDB until they expire; a `sync.Mutex`, which nothing contends for; an expired match as a fallback when asking fails, which would keep a second kind of match for a rare case. +307. **The Games page** (`games-page`): the lead's choices (2026-10-30), for the user to review. + - **The page**: `/games` and the paths under it show the Games page, which loads `GET /api/games/recent` each time it shows, as the Time page loads its report (decision 184), so the service's fetch on request (decision 302) starts when I look. The games sit in the polled section that the PRs and Jira lists share (decision 205), "Recently *played*", so the failure line, its "These games are from …", the empty card and "Couldn't load the games." are theirs. A 404 says "Exophase isn't set up." in the page's status line (decision 206), which is also what the page says until the gateway forwards to `games` (decision 300). + - **The rows**: none is a link, since the answer carries no address for a game's page. The label is the day I last played the game, in the device's time zone, "Today", "Yesterday", then the date, "19 Oct", with no year, since the list spans 14 days. The title is Exophase's. The first detail line has the platforms, split by " · " as the review line's groups are (decision 174); the second the trophies, "31 of 52 trophies · 59%", left out for a game without any, such as a Nintendo one (decision 301). The end has the playtime, "12h 34m", "45m" or "3h", left out when Exophase doesn't know it. + - **The trophies**: "trophies" on every platform, since the answer doesn't say which platforms call them achievements (decision 301). The percentage is rounded down, so only all of them make 100%, and the counts have thousands separators, as "1,234h 5m" does. + - **Stale**: decision 67's badge is an outlined Stale chip after the section's count, once for the list, when the latest fetch failed and the list is from an earlier one. The polled section shows it only when a page asks (`markStale`), so the PRs, Jira and Time lists don't change; whether they should is for the user. The section head's note can end with a chip for it (§7). + - **Before the first success**: the service answers `games: []` with the failure until a fetch succeeds (decision 303), so the page counts the games only once `searched` is set, and shows the failure line alone, with no card, count or chip, as the Time page does with no days yet. + - **Rejected**: a Stale chip on each row, which says one thing as many times as there are games; the failure line alone, short of decision 67's badge; the platform as the row's label, which can be two or three platforms long and would widen the label column for every row; the percentage rounded to the nearest, which shows 100% with a trophy to go; the Time page's `formatHours`, which writes 45 minutes as "0h 45m", as a time sheet does, and would tie the games to the Time page's code; the Stale chip on every polled list, which changes pages that this step doesn't own. + Decisions 400 to 499 are the vault session's: `vault-edit-sync`, then `copilot-planner`. 400. **Editing notes, the first steps** (`vault-plan`, `note-etag`, `note-editor`): the lead's choices (2026-10-30), for the user to review.