48 KiB
Downloaded Games Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a Downloaded games page, a downloaded filter on the browse pages, and three admin settings controlling the page, guest access to it, and whether the filter starts on.
Architecture: "Downloaded" means a game_data row with presentOnDisk = 1, so
the filter is a single SQL EXISTS clause. A reconciler service rebuilds that flag
from the ZIP files on disk to survive Launcher syncs and to backfill games that were
downloaded through the Play path, which previously never recorded anything. The
three settings are public boolean rows in the existing features settings category.
Tech Stack: Express + TypeScript + better-sqlite3 (backend, port 3100), React + Vite + TanStack Query + Zustand + Tailwind (frontend), Zod for validation, Vitest for tests on both sides.
Spec: docs/superpowers/specs/2026-08-11-downloaded-games-design.md
Global Constraints
- No
anytypes; useunknown, generics, or real interfaces. - No non-null assertions (
!); use?? defaultValueor explicit null checks. - Use
??not||for defaults. - Throw
AppError, not plainError, from request paths. - Every async Express handler is wrapped in
asyncHandler(). - Never write to
flashpoint.sqliteexcept throughGameDatabaseUpdater, which documents the deliberate exception atbackend/src/services/GameDatabaseUpdater.ts:19-25. - Frontend:
@/imports, theme tokens (text-muted-foreground, nottext-gray-400),loggernotconsole, all API calls through@/lib/api. - Fire-and-forget promises get a
.catch(). - Before each commit:
npx prettier --write <files>thennpm run typecheck. - Backend tests:
cd backend && npx vitest run <file>. Frontend tests:cd frontend && npx vitest run <file>. - Known pre-existing failure:
frontend/src/components/auth/ProtectedRoute.test.tsxfails all 13 tests on a clean checkout (zustand/localStorage in the test env). Do not try to fix it; do not treat it as caused by your change.
Task 1: Backend search filter
Files:
- Modify:
backend/src/services/GameService.ts(GameSearchQueryat line 5,searchGamesWHERE-clause block around lines 390-470) - Modify:
backend/src/services/GameSearchCache.ts:49-75(generateCacheKey) - Modify:
backend/src/routes/games.ts:28-50(searchBodySchema),:57-68(logActivity filter list),:84-105(query pass-through) - Test:
backend/src/services/GameService.downloaded.test.ts
Interfaces:
-
Consumes: nothing from earlier tasks.
-
Produces:
GameSearchQuery.downloaded?: boolean—truerestricts results to games having agame_datarow withpresentOnDisk = 1;falseandundefinedboth mean no restriction. Task 7 sends this field from the frontend. -
Step 1: Write the failing test
Create backend/src/services/GameService.downloaded.test.ts:
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import BetterSqlite3 from 'better-sqlite3';
vi.mock('./DatabaseService', () => ({
DatabaseService: {
all: vi.fn(),
get: vi.fn(),
getDatabase: vi.fn(),
},
}));
vi.mock('../utils/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));
import { DatabaseService } from './DatabaseService';
import { GameService } from './GameService';
let db: BetterSqlite3.Database;
function seed(): void {
db.exec(`
CREATE TABLE game (
id TEXT PRIMARY KEY, title TEXT, alternateTitles TEXT, series TEXT,
developer TEXT, publisher TEXT, platformName TEXT, library TEXT,
orderTitle TEXT, dateAdded TEXT, dateModified TEXT, releaseDate TEXT,
playMode TEXT, language TEXT, tagsStr TEXT, broken INTEGER DEFAULT 0,
extreme INTEGER DEFAULT 0, activeDataId INTEGER, activeDataOnDisk INTEGER,
launchCommand TEXT, archiveState INTEGER
);
CREATE TABLE game_data (
id INTEGER PRIMARY KEY, gameId TEXT, dateAdded TEXT, sha256 TEXT,
path TEXT, presentOnDisk INTEGER DEFAULT 0, launchCommand TEXT
);
INSERT INTO game (id, title, orderTitle, platformName, library) VALUES
('11111111-1111-1111-1111-111111111111', 'Downloaded Game', 'downloaded game', 'HTML5', 'arcade'),
('22222222-2222-2222-2222-222222222222', 'Absent Game', 'absent game', 'HTML5', 'arcade');
INSERT INTO game_data (id, gameId, dateAdded, presentOnDisk) VALUES
(1, '11111111-1111-1111-1111-111111111111', '2020-01-01T00:00:00.000Z', 1),
(2, '22222222-2222-2222-2222-222222222222', '2020-01-01T00:00:00.000Z', 0);
`);
}
beforeEach(() => {
db = new BetterSqlite3(':memory:');
seed();
vi.mocked(DatabaseService.all).mockImplementation(
(sql: string, params: unknown[] = []) => db.prepare(sql).all(...params) as unknown[]
);
vi.mocked(DatabaseService.get).mockImplementation(
(sql: string, params: unknown[] = []) => db.prepare(sql).get(...params) as unknown
);
});
afterEach(() => {
db.close();
vi.clearAllMocks();
});
const baseQuery = {
sortBy: 'title',
sortOrder: 'asc' as const,
page: 1,
limit: 50,
showBroken: false,
showExtreme: false,
fields: 'list' as const,
};
describe('GameService downloaded filter', () => {
it('returns only games with presentOnDisk=1 when downloaded is true', async () => {
const result = await new GameService().searchGames({ ...baseQuery, downloaded: true });
expect(result.data.map((g) => g.title)).toEqual(['Downloaded Game']);
});
it('returns all games when downloaded is undefined', async () => {
const result = await new GameService().searchGames(baseQuery);
expect(result.data).toHaveLength(2);
});
it('returns all games when downloaded is false', async () => {
const result = await new GameService().searchGames({ ...baseQuery, downloaded: false });
expect(result.data).toHaveLength(2);
});
});
- Step 2: Run test to verify it fails
Run: cd backend && npx vitest run src/services/GameService.downloaded.test.ts
Expected: FAIL — the first test returns 2 games, because downloaded is ignored.
- Step 3: Add the field to the query interface
In backend/src/services/GameService.ts, inside GameSearchQuery after library?: string;:
/** true = only games with a game_data row where presentOnDisk = 1; false/undefined = no restriction */
downloaded?: boolean;
- Step 4: Add the SQL condition
In searchGames, directly after the if (query.library) { ... } block:
if (query.downloaded === true) {
sql += ` AND EXISTS (
SELECT 1 FROM game_data gd
WHERE gd.gameId = g.id AND gd.presentOnDisk = 1
)`;
}
- Step 5: Run test to verify it passes
Run: cd backend && npx vitest run src/services/GameService.downloaded.test.ts
Expected: PASS (3 tests).
- Step 6: Include the field in the cache key
In backend/src/services/GameSearchCache.ts, inside generateCacheKey's
normalizedQuery, after the library line:
downloaded: query.downloaded ?? '',
?? matters here: || would collapse false and undefined into the same key.
- Step 7: Accept the field on the route
In backend/src/routes/games.ts, add to searchBodySchema after the library line:
downloaded: z.boolean().optional(),
Add 'downloaded' to the filters array in the logActivity callback, and pass it
through to GameSearchCache.searchGames:
downloaded: body.downloaded,
- Step 8: Verify and commit
cd backend && npx vitest run src/services/GameService.downloaded.test.ts
cd .. && npx prettier --write backend/src/services/GameService.ts backend/src/services/GameSearchCache.ts backend/src/routes/games.ts backend/src/services/GameService.downloaded.test.ts
npm run typecheck
git add backend/src
git commit -m "feat(games): filter search results by downloaded state"
Task 2: Downloaded-aware filter options
Without this, the dropdowns on the Downloaded page list values from the whole catalog and most selections return nothing.
Files:
- Modify:
backend/src/services/GameService.ts(FilterOptionsParamsat lines 86-98,getFilterOptionsCacheKeyat lines 135-168,buildFilterOptionsConditionsat lines 836+) - Modify:
backend/src/routes/games.ts:113-129(filterOptionsQuerySchema) and the params object it builds around line 148 - Test:
backend/src/services/GameService.downloaded.test.ts(extend Task 1's file)
Interfaces:
-
Consumes: Task 1's
downloadedsemantics. -
Produces:
FilterOptionsParams.downloaded?: boolean, honoured by everyget*Optionsmethod and bygetYearRange. -
Step 1: Write the failing test
Append to backend/src/services/GameService.downloaded.test.ts:
describe('filter options honour the downloaded filter', () => {
beforeEach(() => {
db.exec(`
UPDATE game SET series = 'Downloaded Series'
WHERE id = '11111111-1111-1111-1111-111111111111';
UPDATE game SET series = 'Absent Series'
WHERE id = '22222222-2222-2222-2222-222222222222';
`);
});
it('lists only series belonging to downloaded games', () => {
const options = new GameService().getSeriesOptions({ downloaded: true });
expect(options).toEqual(['Downloaded Series']);
});
it('lists every series when the filter is off', () => {
const options = new GameService().getSeriesOptions({});
expect(options).toHaveLength(2);
});
});
- Step 2: Run test to verify it fails
Run: cd backend && npx vitest run src/services/GameService.downloaded.test.ts
Expected: FAIL — the first new test returns both series.
- Step 3: Add the field and the shared condition
In FilterOptionsParams, after library?: string;:
downloaded?: boolean;
In buildFilterOptionsConditions, after the params?.library block:
if (params?.downloaded === true) {
// These queries select `FROM game` with no alias, and game_data has its own
// `id` column, so the outer column must be qualified as game.id.
conditions.push(
'EXISTS (SELECT 1 FROM game_data gd WHERE gd.gameId = game.id AND gd.presentOnDisk = 1)'
);
}
- Step 4: Add it to the options cache key
In getFilterOptionsCacheKey, add params?.downloaded === true as another
hasDynamicFilters disjunct, add downloaded: params?.downloaded ?? false to the
dynamic key object, and extend the static key so the two populations never share an
entry:
return {
key: `${params?.platform ?? ''}_${params?.library ?? ''}_${params?.downloaded === true ? 'dl' : ''}`,
isDynamic: false,
};
- Step 5: Run test to verify it passes
Run: cd backend && npx vitest run src/services/GameService.downloaded.test.ts
Expected: PASS (5 tests).
- Step 6: Accept the param on the route
In filterOptionsQuerySchema add downloaded: booleanSchema.optional(), (the
booleanSchema preprocessor already defined at the top of the file handles the
'false' string correctly), and pass downloaded: query.downloaded into the params
object built for the service call.
- Step 7: Verify and commit
cd backend && npx vitest run src/services/GameService.downloaded.test.ts
cd .. && npx prettier --write backend/src/services/GameService.ts backend/src/routes/games.ts backend/src/services/GameService.downloaded.test.ts
npm run typecheck
git add backend/src
git commit -m "feat(games): narrow filter options to downloaded games"
Task 3: Downloaded reconciler
Rebuilds presentOnDisk from the ZIPs actually on disk. This is what backfills
games downloaded through the Play path and what restores the flag after a Launcher
sync overwrites the local database copy.
Files:
- Create:
backend/src/services/DownloadedReconciler.ts - Modify:
backend/src/services/DatabaseService.ts(addnoteSelfWrite, call the reconciler at the end ofsyncAndReloadaround line 352) - Modify:
backend/src/server.ts:246(run once afterDatabaseService.initialize()) - Test:
backend/src/services/DownloadedReconciler.test.ts
Interfaces:
-
Consumes:
GameDataDownloader.getFilename(gameId, dateAdded)frombackend/src/game/services/GameDataDownloader.ts:75. -
Produces:
DownloadedReconciler.reconcile(): Promise<{ scanned: number; marked: number }>DatabaseService.noteSelfWrite(): void— re-stats the source database and advanceslastModifiedTimeso the file watcher does not treat our own write as a Launcher change. Task 4 calls it.
-
Step 1: Write the failing test
Create backend/src/services/DownloadedReconciler.test.ts:
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import BetterSqlite3 from 'better-sqlite3';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
vi.mock('../utils/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));
let gamesDir: string;
vi.mock('../config', () => ({
config: {
get flashpointGamesPath() {
return gamesDir;
},
flashpointPath: 'C:/Flashpoint',
},
}));
vi.mock('./DatabaseService', () => ({
DatabaseService: { getDatabase: vi.fn(), noteSelfWrite: vi.fn() },
}));
import { DatabaseService } from './DatabaseService';
import { DownloadedReconciler } from './DownloadedReconciler';
const GAME_ID = '11111111-1111-1111-1111-111111111111';
const DATE_ADDED = '2020-01-01T00:00:00.000Z';
let db: BetterSqlite3.Database;
beforeEach(async () => {
gamesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fp-games-'));
db = new BetterSqlite3(':memory:');
db.exec(`
CREATE TABLE game (id TEXT PRIMARY KEY, activeDataId INTEGER, activeDataOnDisk INTEGER);
CREATE TABLE game_data (
id INTEGER PRIMARY KEY, gameId TEXT, dateAdded TEXT, path TEXT,
presentOnDisk INTEGER DEFAULT 0
);
INSERT INTO game (id, activeDataId, activeDataOnDisk) VALUES ('${GAME_ID}', 1, 0);
INSERT INTO game_data (id, gameId, dateAdded, presentOnDisk)
VALUES (1, '${GAME_ID}', '${DATE_ADDED}', 0);
`);
vi.mocked(DatabaseService.getDatabase).mockReturnValue(db);
});
afterEach(async () => {
db.close();
await fs.rm(gamesDir, { recursive: true, force: true });
vi.clearAllMocks();
});
describe('DownloadedReconciler', () => {
it('marks a game whose ZIP is present on disk', async () => {
const timestamp = new Date(DATE_ADDED).getTime();
await fs.writeFile(path.join(gamesDir, `${GAME_ID}-${timestamp}.zip`), 'x');
const result = await DownloadedReconciler.reconcile();
expect(result.marked).toBe(1);
const row = db.prepare('SELECT presentOnDisk FROM game_data WHERE id = 1').get() as {
presentOnDisk: number;
};
expect(row.presentOnDisk).toBe(1);
const game = db.prepare(`SELECT activeDataOnDisk FROM game WHERE id = '${GAME_ID}'`).get() as {
activeDataOnDisk: number;
};
expect(game.activeDataOnDisk).toBe(1);
});
it('ignores files that are not game ZIPs', async () => {
await fs.writeFile(path.join(gamesDir, 'notes.txt'), 'x');
await fs.writeFile(path.join(gamesDir, 'random-name.zip'), 'x');
const result = await DownloadedReconciler.reconcile();
expect(result.marked).toBe(0);
});
it('marks nothing and does not throw when the directory is unreadable', async () => {
await fs.rm(gamesDir, { recursive: true, force: true });
const result = await DownloadedReconciler.reconcile();
expect(result).toEqual({ scanned: 0, marked: 0 });
});
it('never clears the flag for a game whose ZIP is missing', async () => {
db.prepare('UPDATE game_data SET presentOnDisk = 1 WHERE id = 1').run();
await DownloadedReconciler.reconcile();
const row = db.prepare('SELECT presentOnDisk FROM game_data WHERE id = 1').get() as {
presentOnDisk: number;
};
expect(row.presentOnDisk).toBe(1);
});
});
- Step 2: Run test to verify it fails
Run: cd backend && npx vitest run src/services/DownloadedReconciler.test.ts
Expected: FAIL — cannot resolve ./DownloadedReconciler.
- Step 3: Implement the reconciler
Create backend/src/services/DownloadedReconciler.ts:
import fs from 'fs/promises';
import path from 'path';
import { config } from '../config';
import { logger } from '../utils/logger';
import { DatabaseService } from './DatabaseService';
const ZIP_NAME_PATTERN =
/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(\d+)\.zip$/i;
interface PendingRow {
id: number;
gameId: string;
dateAdded: string;
}
/**
* Rebuilds game_data.presentOnDisk from the ZIP files actually present in the games
* directory. Marks only: a missing ZIP is not proof of absence, because the Launcher
* may store game data outside config.flashpointGamesPath.
*/
export class DownloadedReconciler {
static async reconcile(): Promise<{ scanned: number; marked: number }> {
let files: string[];
try {
files = await fs.readdir(config.flashpointGamesPath);
} catch (error: unknown) {
logger.warn(
{ error, path: config.flashpointGamesPath },
'[DownloadedReconciler] Games directory unreadable, skipping reconciliation'
);
return { scanned: 0, marked: 0 };
}
const onDisk = new Map<string, Set<number>>();
for (const file of files) {
const match = ZIP_NAME_PATTERN.exec(file);
if (!match) continue;
const gameId = match[1].toLowerCase();
const timestamp = Number(match[2]);
const timestamps = onDisk.get(gameId) ?? new Set<number>();
timestamps.add(timestamp);
onDisk.set(gameId, timestamps);
}
if (onDisk.size === 0) {
return { scanned: files.length, marked: 0 };
}
const db = DatabaseService.getDatabase();
const pending = db
.prepare(
`SELECT id, gameId, dateAdded FROM game_data WHERE presentOnDisk = 0 OR presentOnDisk IS NULL`
)
.all() as PendingRow[];
const toMark = pending.filter((row) => {
const timestamps = onDisk.get(row.gameId.toLowerCase());
if (!timestamps) return false;
const parsed = new Date(row.dateAdded).getTime();
return !isNaN(parsed) && timestamps.has(parsed);
});
if (toMark.length === 0) {
return { scanned: files.length, marked: 0 };
}
const markData = db.prepare('UPDATE game_data SET presentOnDisk = 1 WHERE id = ?');
const markGame = db.prepare(
'UPDATE game SET activeDataOnDisk = 1 WHERE id = ? AND activeDataId = ?'
);
db.transaction(() => {
for (const row of toMark) {
markData.run(row.id);
markGame.run(row.gameId, row.id);
}
})();
DatabaseService.noteSelfWrite();
logger.info(
{ scanned: files.length, marked: toMark.length },
'[DownloadedReconciler] Reconciled downloaded game data'
);
return { scanned: files.length, marked: toMark.length };
}
}
- Step 4: Add
noteSelfWriteto DatabaseService
In backend/src/services/DatabaseService.ts, add a public static method near
syncAndReload:
/**
* Record that this process just wrote to the database, so the file watcher does not
* mistake our own write for a Launcher change and trigger a full reload plus cache flush.
*/
static noteSelfWrite(): void {
try {
const stats = fs.statSync(this.sourceDbPath);
if (stats.mtimeMs > this.lastModifiedTime) {
this.lastModifiedTime = stats.mtimeMs;
}
} catch (error: unknown) {
logger.debug('[DatabaseService] Could not stat source database after self-write:', error);
}
}
- Step 5: Run test to verify it passes
Run: cd backend && npx vitest run src/services/DownloadedReconciler.test.ts
Expected: PASS (4 tests).
- Step 6: Wire the reconciler into startup and sync
In backend/src/server.ts, immediately after await DatabaseService.initialize();:
DownloadedReconciler.reconcile().catch((error: unknown) =>
logger.error('[Startup] Downloaded reconciliation failed:', error)
);
with import { DownloadedReconciler } from './services/DownloadedReconciler'; added
to the imports.
In DatabaseService.syncAndReload, after the cache invalidation block (the
logger.info('Database reloaded, caches invalidated') line), re-run reconciliation
because a local-copy sync overwrites our flags. Use a lazy require to match the
existing cycle-avoidance pattern used for GameSearchCache in this file:
const { DownloadedReconciler } = require('./DownloadedReconciler') as {
DownloadedReconciler: { reconcile: () => Promise<{ scanned: number; marked: number }> };
};
DownloadedReconciler.reconcile().catch((error: unknown) =>
logger.error('[DatabaseService] Post-sync reconciliation failed:', error)
);
- Step 7: Verify and commit
cd backend && npx vitest run src/services/DownloadedReconciler.test.ts
cd .. && npx prettier --write backend/src/services/DownloadedReconciler.ts backend/src/services/DownloadedReconciler.test.ts backend/src/services/DatabaseService.ts backend/src/server.ts
npm run typecheck
git add backend/src
git commit -m "feat(games): reconcile downloaded state from disk"
Task 4: Mark downloads from the Play path
Files:
- Modify:
backend/src/game/gamezipserver.ts(mountZipparams at lines 57-64, thedownloadAndMountInBackgroundsignature at line 328 and its call site at line 152, the success block at lines 364-386) - Modify:
backend/src/services/GameDataService.ts:92-98(pass the id intomountZip)
Interfaces:
-
Consumes:
GameDatabaseUpdater.markAsDownloaded(gameDataId: number, filePath: string): Promise<GameData>,DatabaseService.noteSelfWrite()from Task 3. -
Produces: no new exports; the Play path now records
presentOnDisk = 1. -
Step 1: Thread the game_data id through the mount call
GameDataService.getGameDataEntry already selects id, so no extra query is needed.
In backend/src/services/GameDataService.ts, extend the mountZip call:
const result = await gameZipServer.mountZip({
id: mountId,
zipPath,
gameId: gameDataEntry?.gameId ?? gameId,
dateAdded: gameDataEntry?.dateAdded,
sha256: gameDataEntry?.sha256,
gameDataId: gameDataEntry?.id,
});
In backend/src/game/gamezipserver.ts, add gameDataId?: number; to the mountZip
params type, destructure it, and pass it as a new trailing argument to
downloadAndMountInBackground (add gameDataId: number | undefined as the last
parameter of that method).
- Step 2: Mark the row on successful download
In downloadAndMountInBackground, inside if (result.success && result.filePath),
after the successful zipManager.mount(...) and DownloadRegistry.complete(gameId)
calls:
if (gameDataId !== undefined) {
try {
await GameDatabaseUpdater.markAsDownloaded(gameDataId, result.filePath);
DatabaseService.noteSelfWrite();
GameSearchCache.clearCache();
GameService.clearFilterOptionsCache();
} catch (error: unknown) {
// A failed flag update must not fail the download: the game is mounted and
// playable, and the reconciler will mark it on the next run.
logger.warn(
`[GameZipServer] Could not record downloaded state for ${gameId}: ${
error instanceof Error ? error.message : 'unknown error'
}`
);
}
}
Add the four imports at the top of the file
(GameDatabaseUpdater, DatabaseService, GameSearchCache, GameService — all from
../services/...). The file already imports from ../services, so this introduces no
cycle.
Note the checksum caveat: GameDataDownloader only verifies SHA-256 when the
game_data row carries one. We mark regardless, because the ZIP mounted successfully,
which is a stronger signal than an absent checksum.
- Step 3: Verify the whole backend still compiles and tests pass
npm run typecheck
cd backend && npx vitest run
Expected: typecheck clean; the backend suite passes.
- Step 4: Commit
cd .. && npx prettier --write backend/src/game/gamezipserver.ts backend/src/services/GameDataService.ts
git add backend/src
git commit -m "fix(games): record downloaded state after play-path download"
Task 5: Settings plumbing
Files:
- Create:
backend/src/migrations/004_downloaded_settings.sql - Modify:
frontend/src/types/settings.ts:12-21(FeatureSettings) - Modify:
frontend/src/hooks/useFeatureFlags.ts - Modify:
frontend/src/components/settings/FeaturesSettingsTab.tsx
Interfaces:
-
Produces, from
useFeatureFlags():enableDownloadedPage: boolean— admin bypass applies, defaults true.enableDownloadedPageForGuests: boolean— no bypass, defaults false.enableDownloadedFilterDefault: boolean— no bypass, defaults false. Tasks 7 and 8 consume all three.
-
Step 1: Write the migration
Create backend/src/migrations/004_downloaded_settings.sql:
-- ===================================
-- Migration 004: Downloaded games page and filter settings
-- ===================================
INSERT OR IGNORE INTO system_settings (key, value, data_type, category, description, is_public, default_value, validation_schema)
VALUES
('features.enable_downloaded_page', '1', 'boolean', 'features', 'Show the Downloaded games page', 1, '1', '{"type":"boolean"}'),
('features.enable_downloaded_page_for_guests', '0', 'boolean', 'features', 'Allow guests to see the Downloaded games page', 1, '0', '{"type":"boolean"}'),
('features.enable_downloaded_filter_default', '0', 'boolean', 'features', 'Turn the downloaded filter on by default when browsing', 1, '0', '{"type":"boolean"}');
SystemSettingsService maps enable_downloaded_page to enableDownloadedPage
automatically via its snakeToCamel/camelToSnake helpers, so no service change is
needed.
- Step 2: Extend the frontend settings type
In frontend/src/types/settings.ts, add to FeatureSettings:
enableDownloadedPage: boolean;
enableDownloadedPageForGuests: boolean;
enableDownloadedFilterDefault: boolean;
- Step 3: Expose the flags
In frontend/src/hooks/useFeatureFlags.ts, add to the returned object:
enableDownloadedPage: isAdmin || (features.enableDownloadedPage ?? true),
// Default-off flags are read strictly: the `?? true` fallback used above would
// invert them while public settings are still loading or if the key is missing.
enableDownloadedPageForGuests: features.enableDownloadedPageForGuests === true,
enableDownloadedFilterDefault: features.enableDownloadedFilterDefault === true,
- Step 4: Add the three switches
In frontend/src/components/settings/FeaturesSettingsTab.tsx, after the existing
"I'm Feeling Lucky" block, add the page switch, then the guest switch rendered only
when the page flag is on, then the default switch:
{/* Downloaded page */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="enable-downloaded-page" className="text-base">
Enable Downloaded Page
</Label>
<p className="text-sm text-muted-foreground">
Show a page listing only games whose files are downloaded.
</p>
</div>
<Switch
id="enable-downloaded-page"
checked={featureSettings.enableDownloadedPage !== false}
onCheckedChange={(checked: boolean) => {
updateSystemSettings.mutate({
category: 'features',
settings: { enableDownloadedPage: checked },
});
}}
disabled={updateSystemSettings.isPending}
/>
</div>
{featureSettings.enableDownloadedPage !== false ? (
<div className="flex items-center justify-between pl-6 border-l-2 border-border">
<div className="space-y-0.5">
<Label htmlFor="enable-downloaded-page-guests" className="text-base">
Show Downloaded Page To Guests
</Label>
<p className="text-sm text-muted-foreground">
Let signed-out guests open the Downloaded page.
</p>
</div>
<Switch
id="enable-downloaded-page-guests"
checked={featureSettings.enableDownloadedPageForGuests === true}
onCheckedChange={(checked: boolean) => {
updateSystemSettings.mutate({
category: 'features',
settings: { enableDownloadedPageForGuests: checked },
});
}}
disabled={updateSystemSettings.isPending}
/>
</div>
) : null}
{/* Downloaded filter default */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="enable-downloaded-filter-default" className="text-base">
Filter To Downloaded By Default
</Label>
<p className="text-sm text-muted-foreground">
Browse pages start with the downloaded filter on. This one also applies to
admins; anyone can still switch it off per page.
</p>
</div>
<Switch
id="enable-downloaded-filter-default"
checked={featureSettings.enableDownloadedFilterDefault === true}
onCheckedChange={(checked: boolean) => {
updateSystemSettings.mutate({
category: 'features',
settings: { enableDownloadedFilterDefault: checked },
});
}}
disabled={updateSystemSettings.isPending}
/>
</div>
- Step 5: Verify the migration applies
cd backend && npm run dev
Watch for [UserDB] Running migration: 004_downloaded_settings in the log, then stop
the server with Ctrl+C. Confirm the process is gone before continuing.
- Step 6: Commit
npx prettier --write frontend/src/types/settings.ts frontend/src/hooks/useFeatureFlags.ts frontend/src/components/settings/FeaturesSettingsTab.tsx
npm run typecheck
git add backend/src/migrations frontend/src
git commit -m "feat(settings): add downloaded page and filter settings"
Task 6: Tri-state URL parameter
Files:
- Modify:
frontend/src/lib/filterUrlCompression.ts(FilterUrlParamsat lines 6-21,CATEGORY_TO_ABBRat lines 24-34,toUrlSafeFormatat lines 92-111,fromUrlSafeFormatat lines 116-173,parseFilterParamslegacy branch at lines 355-370,hasLegacyParamsat lines 403-418) - Test:
frontend/src/lib/filterUrlCompression.test.ts
Interfaces:
-
Produces:
FilterUrlParams.downloaded?: '0' | '1'encoded under the keyw.'1'on,'0'off, absent means "use the admin default". Task 7 resolves it. -
Step 1: Write the failing test
Create frontend/src/lib/filterUrlCompression.test.ts:
import { describe, it, expect } from 'vitest';
import {
buildFilterSearchParams,
parseFilterParams,
compressFilters,
decompressFilters,
} from './filterUrlCompression';
describe('downloaded filter URL round-trip', () => {
it('round-trips an explicit on', () => {
const encoded = compressFilters({ downloaded: '1' });
expect(encoded).not.toBeNull();
expect(decompressFilters(encoded ?? '')?.downloaded).toBe('1');
});
it('round-trips an explicit off rather than dropping it', () => {
const encoded = compressFilters({ downloaded: '0' });
expect(encoded).not.toBeNull();
expect(decompressFilters(encoded ?? '')?.downloaded).toBe('0');
});
it('survives buildFilterSearchParams and parseFilterParams', () => {
const params = buildFilterSearchParams({ downloaded: '0', search: 'sonic' });
const parsed = parseFilterParams(params);
expect(parsed.downloaded).toBe('0');
expect(parsed.search).toBe('sonic');
});
it('leaves downloaded undefined when absent', () => {
const parsed = parseFilterParams(new URLSearchParams());
expect(parsed.downloaded).toBeUndefined();
});
});
- Step 2: Run test to verify it fails
Run: cd frontend && npx vitest run src/lib/filterUrlCompression.test.ts
Expected: FAIL — downloaded is not a known param, so it is dropped.
- Step 3: Implement the encoding
In frontend/src/lib/filterUrlCompression.ts:
Add to FilterUrlParams:
/** '1' = downloaded only, '0' = explicitly off, undefined = use the admin default */
downloaded?: '0' | '1';
Add to CATEGORY_TO_ABBR:
Downloaded: 'W',
In toUrlSafeFormat, before the fo line — note the !== undefined guard, since a
truthiness check would silently drop '0':
if (params.downloaded !== undefined) parts.push(`w.${params.downloaded}`);
In fromUrlSafeFormat's switch:
case 'w':
if (value === '0' || value === '1') params.downloaded = value;
break;
In the legacy branch of parseFilterParams, add:
downloaded: searchParams.get('downloaded') === '1'
? '1'
: searchParams.get('downloaded') === '0'
? '0'
: undefined,
and add 'downloaded' to the legacyKeys array in hasLegacyParams.
- Step 4: Run test to verify it passes
Run: cd frontend && npx vitest run src/lib/filterUrlCompression.test.ts
Expected: PASS (4 tests).
- Step 5: Commit
cd .. && npx prettier --write frontend/src/lib/filterUrlCompression.ts frontend/src/lib/filterUrlCompression.test.ts
npm run typecheck
git add frontend/src/lib
git commit -m "feat(filters): encode downloaded filter in the URL"
Task 7: Filter UI and default resolution
Files:
- Modify:
frontend/src/types/game.ts:48-66(GameFilters) - Modify:
frontend/src/lib/api/games.ts:11-38(search POST body) and:39-57(getFilterOptionsparams type) - Modify:
frontend/src/hooks/useFilterOptions.ts:6-18(FilterOptionsParams) - Modify:
frontend/src/components/library/GameBrowseLayout.tsx(filters memo lines 70-89, filterOptionsParams lines 93-108, chips memo lines 164-294,handleRemoveChiplines 306-360,handleRemoveWithChildrenlines 363-399,handleClearAllFilterslines 401-407) - Modify:
frontend/src/components/search/FilterPanel.tsx(props at lines 17-32, desktop row at lines 224-233, mobile row at lines 236-246)
Interfaces:
-
Consumes:
FilterUrlParams.downloaded(Task 6),enableDownloadedFilterDefault(Task 5),GameSearchQuery.downloaded(Task 1). -
Produces:
GameBrowseLayoutProps.library?: 'arcade' | 'theatre'andGameBrowseLayoutProps.forceDownloaded?: boolean, both consumed by Task 8. -
Step 1: Add the field to the API layer
In frontend/src/types/game.ts, add to GameFilters:
downloaded?: boolean;
In frontend/src/lib/api/games.ts, add to the body object inside search, directly
after the library line:
downloaded: filters.downloaded,
and add downloaded?: boolean; to the inline params type of getFilterOptions, which
already forwards the whole object as query params.
In frontend/src/hooks/useFilterOptions.ts, add downloaded?: boolean; to
FilterOptionsParams after library?: string;. The hook builds its query key from
this object, so the new field participates in cache invalidation automatically.
- Step 2: Resolve the tri-state in GameBrowseLayout
Add the props and resolution:
interface GameBrowseLayoutProps {
title: string;
library?: 'arcade' | 'theatre';
platform?: string;
headerContent?: ReactNode;
breadcrumbContext?: BreadcrumbContext;
sectionKey?: string | null;
/** Pin the downloaded filter on and hide its switch (used by the Downloaded page) */
forceDownloaded?: boolean;
}
const { enableDownloadedFilterDefault } = useFeatureFlags();
const downloaded = useMemo(() => {
if (forceDownloaded) return true;
if (urlParams.downloaded === '1') return true;
if (urlParams.downloaded === '0') return false;
return enableDownloadedFilterDefault;
}, [forceDownloaded, urlParams.downloaded, enableDownloadedFilterDefault]);
Add downloaded: downloaded ? true : undefined to the filters memo (and its
dependency array) and downloaded: filters.downloaded to filterOptionsParams.
Sending undefined rather than false keeps the request body and the backend cache
key clean.
- Step 3: Add the chip
In the filterChips memo, after the platform chip:
if (filters.downloaded && !forceDownloaded) {
chips.push({
id: 'downloaded',
label: 'Downloaded',
value: 'Downloaded only',
category: 'Downloaded',
order: getOrder('Downloaded'),
});
}
Add forceDownloaded to the memo's dependency array.
- Step 4: Make removal write an explicit off
This is the critical case: when the admin default is on, clearing the param means
"use the default", which immediately re-applies the filter. In handleRemoveChip,
alongside the existing chipId === 'platform' branch:
} else if (chipId === 'downloaded') {
// Clearing the param would fall back to the admin default and re-apply the
// filter, so an explicit off is required when the default is on.
newParams.downloaded = enableDownloadedFilterDefault ? '0' : undefined;
categoryRemoved = 'Downloaded';
Add Downloaded: ['downloaded'] to categoryToParamKeys in
handleRemoveWithChildren, and after that function's param-clearing loop:
if (categoriesToRemove.includes('Downloaded') && enableDownloadedFilterDefault) {
newParams.downloaded = '0';
}
In handleClearAllFilters:
const handleClearAllFilters = useCallback(() => {
const newParams: FilterUrlParams = {
sortBy: urlParams.sortBy,
sortOrder: urlParams.sortOrder,
downloaded: enableDownloadedFilterDefault ? '0' : undefined,
};
setSearchParams(buildFilterSearchParams(newParams));
}, [urlParams.sortBy, urlParams.sortOrder, enableDownloadedFilterDefault, setSearchParams]);
Add enableDownloadedFilterDefault to the dependency arrays of the two callbacks
above as well.
- Step 5: Add the switch to the filter panel
In FilterPanel, add two props:
/** Current resolved value of the downloaded filter */
downloaded?: boolean;
/** Hide the downloaded switch (the Downloaded page pins it on) */
showDownloadedFilter?: boolean;
Default showDownloadedFilter = true, and render this beside YearRangeFilter in
both the desktop and mobile rows (give the two instances distinct ids):
{showDownloadedFilter ? (
<div className="flex items-center gap-2">
<Switch
id="downloaded-filter-desktop"
checked={downloaded === true}
onCheckedChange={(checked: boolean) => {
const newParams: FilterUrlParams = {
...urlParams,
downloaded: checked ? '1' : '0',
page: undefined,
fo: getUpdatedFilterOrder(urlParams.fo, 'Downloaded', checked ? 'add' : 'remove'),
};
setSearchParams(buildFilterSearchParams(newParams));
}}
/>
<Label htmlFor="downloaded-filter-desktop" className="text-sm whitespace-nowrap">
Downloaded only
</Label>
</div>
) : null}
Always write an explicit '1' or '0' here — never clear the key — so the control
reflects the user's choice rather than snapping back to the admin default. Import
Switch from @/components/ui/switch and Label from @/components/ui/label.
Pass both new props from GameBrowseLayout:
downloaded={downloaded}
showDownloadedFilter={!forceDownloaded}
- Step 6: Verify manually
npm run dev
Open http://localhost:5173/browse. Confirm: the switch turns the filter on and the
result count drops; the chip appears; removing the chip turns it off; with
Filter To Downloaded By Default enabled in Settings, removing the chip still turns
the filter off and Clear All does not re-apply it. Stop the dev server with Ctrl+C and
confirm the processes are gone.
- Step 7: Commit
npx prettier --write frontend/src/types/game.ts frontend/src/lib/api/games.ts frontend/src/components/library/GameBrowseLayout.tsx frontend/src/components/search/FilterPanel.tsx
npm run typecheck
git add frontend/src
git commit -m "feat(filters): add downloaded filter to browse pages"
Task 8: Downloaded page, route, and sidebar
Files:
- Create:
frontend/src/views/DownloadedView.tsx - Modify:
frontend/src/App.tsx(lazy import block and the route list near/favoritesat line 439) - Modify:
frontend/src/components/auth/ProtectedRoute.tsx:7-17(props),:67-79(feature check) - Modify:
frontend/src/components/layout/Sidebar.tsx:48(flags),:78-86(library items),:179-190(section gate)
Interfaces:
-
Consumes:
GameBrowseLayoutprops from Task 7, the three flags from Task 5. -
Produces: route
/downloaded;ProtectedRouteProps.requireFeatureForGuests?: 'enableDownloadedPageForGuests'. -
Step 1: Create the view
Create frontend/src/views/DownloadedView.tsx:
import { GameBrowseLayout } from '@/components/library/GameBrowseLayout';
export function DownloadedView() {
return (
<GameBrowseLayout
title="Downloaded"
forceDownloaded
sectionKey={null}
breadcrumbContext={{ label: 'Downloaded', href: '/downloaded' }}
/>
);
}
BreadcrumbContext is { label: string; href: string; icon?: SectionIcon; parent?: BreadcrumbContext }
(frontend/src/components/common/Breadcrumbs.tsx:6-12) — the key is href, not path.
- Step 2: Extend ProtectedRoute for guests
Guests satisfy requireAuth, so a feature flag alone does not keep them out. Add to
ProtectedRouteProps:
requireFeature?:
| 'enablePlaylists'
| 'enableFavorites'
| 'enableStatistics'
| 'enableDownloadedPage';
/** Checked only when the viewer is a guest; the named flag must be true */
requireFeatureForGuests?: 'enableDownloadedPageForGuests';
Destructure requireFeatureForGuests and add the check immediately after the existing
requireFeature block:
if (requireFeatureForGuests && isGuest && !featureFlags[requireFeatureForGuests]) {
return (
<Navigate
to="/unauthorized"
state={{ requiredFeature: requireFeatureForGuests, fromPath: location.pathname }}
replace
/>
);
}
- Step 3: Register the route
In frontend/src/App.tsx, add the lazy import next to the other views:
const DownloadedView = lazy(() =>
import('@/views/DownloadedView').then((m) => ({ default: m.DownloadedView }))
);
Match the exact lazy-import style already used in the file. Add the route beside
/favorites:
<Route
path="/downloaded"
element={
<ProtectedRoute
requireFeature="enableDownloadedPage"
requireFeatureForGuests="enableDownloadedPageForGuests"
>
<Suspense fallback={<RouteLoadingFallback />}>
<DownloadedView />
</Suspense>
</ProtectedRoute>
}
/>
- Step 4: Add the sidebar entry without leaking the others
The Library block is currently hidden wholesale for guests, so its items never check
guest status themselves. Give each item its own predicate before relaxing the block. In
Sidebar.tsx, extend the flags destructure with enableDownloadedPage and
enableDownloadedPageForGuests, then:
const libraryNavItems: NavItem[] = [
enableDownloadedPage &&
(!isGuest || enableDownloadedPageForGuests) && {
path: '/downloaded',
icon: HardDriveDownload,
label: 'Downloaded',
},
!isGuest && enableFavorites && { path: '/favorites', icon: Heart, label: 'Favorites' },
!isGuest && enablePlaylists && { path: '/playlists', icon: ListVideo, label: 'My Playlists' },
!isGuest &&
enablePlaylists && {
path: '/flashpoint-playlists',
icon: ListIcon,
label: 'Flashpoint Playlists',
},
].filter((item): item is Exclude<typeof item, false> => Boolean(item));
Import HardDriveDownload from lucide-react. Change the section gate from
!isGuest && libraryNavItems.length > 0 to libraryNavItems.length > 0, since each
item now carries its own guest rule.
- Step 5: Verify manually
npm run dev
Check, stopping the server afterwards with Ctrl+C:
/downloadedlists only downloaded games and shows no downloaded switch.- Turning off
Enable Downloaded Pagehides the sidebar entry for a non-admin and redirects/downloadedto/unauthorized. - As a guest with the guest flag off, the entry is hidden and
/downloadedredirects; with it on, both work, and Favorites and Playlists stay hidden.
- Step 6: Commit
npx prettier --write frontend/src/views/DownloadedView.tsx frontend/src/App.tsx frontend/src/components/auth/ProtectedRoute.tsx frontend/src/components/layout/Sidebar.tsx
npm run typecheck
git add frontend/src
git commit -m "feat(games): add downloaded games page"
Task 9: Documentation
Files:
-
Modify:
docs/06-api-reference/games-api.md -
Modify:
docs/06-api-reference/settings-api.md -
Modify:
docs/10-features/02-game-browsing-filtering.md -
Step 1: Document the API parameter
In docs/06-api-reference/games-api.md, add downloaded (boolean, optional) to both
the search request body table and the filter-options query table, described as
"restrict to games whose data is present on disk".
- Step 2: Document the settings
In docs/06-api-reference/settings-api.md, add three rows to the settings table:
| features | enableDownloadedPage | boolean | true | - |
| features | enableDownloadedPageForGuests | boolean | false | - |
| features | enableDownloadedFilterDefault | boolean | false | - |
- Step 3: Document the feature
In docs/10-features/02-game-browsing-filtering.md, add a short section covering the
Downloaded page, the filter, the tri-state URL parameter, and the two limitations from
the spec: games with no game_data rows never appear, and the reconciler only ever
sets the flag, never clears it.
- Step 4: Commit
npx prettier --write docs/06-api-reference/games-api.md docs/06-api-reference/settings-api.md docs/10-features/02-game-browsing-filtering.md
git add docs
git commit -m "docs: document downloaded games page and filter"
Final verification
npm run typecheckpasses.cd backend && npx vitest runpasses.cd frontend && npx vitest run src/lib/filterUrlCompression.test.tspasses (the pre-existingProtectedRoute.test.tsxfailures remain; they are unrelated).npm run buildsucceeds.- No dev servers or watchers left running.