From f62515451b1698328ee880a08c8a4de164c044ce Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 7 Jun 2026 22:29:26 +0200 Subject: [PATCH] feat(ui): label streamed installs as not shareable NEXT_STEPS item 7 needed the installed-but-not-downloaded state to be clear to users. Keep streamed installs in the installed visual state so sorting, filters, and the primary Play action stay unchanged, but make the sharing limitation visible in the UI. Cards now label that state as `Not shareable`, while the detail modal status says `Installed, not shareable`. Downloaded-and-installed games keep the normal `Installed` wording. Test Plan: - just frontend-test - just build - git diff --check - git diff --cached --check Refs: NEXT_STEPS.md item 7 --- NEXT_STEPS.md | 9 ++-- .../src/components/StateChip.tsx | 12 +----- .../src/components/modals/GameDetailModal.tsx | 14 +------ .../src/lib/gameState.ts | 29 +++++++++++++ .../tests/gameState.test.ts | 41 +++++++++++++++++++ 5 files changed, 79 insertions(+), 26 deletions(-) diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 217cd14..4bdd410 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -54,11 +54,12 @@ product-ready. The peer-cli harness now exposes `cancel-download` so cancellation scenarios exercise the same runtime path as the GUI. -7. **Clean product semantics** +7. **Done — Clean product semantics** - Decide how the UI labels this state. It is installed but not downloaded, so - “Local only” is technically correct, but users may need a clear affordance - like “Installed, not shareable”. + The UI now keeps streamed installs in the installed visual state while making + the sharing limitation explicit: cards show `Not shareable`, and the detail + modal status shows `Installed, not shareable`. Downloaded-and-installed games + keep the normal `Installed` label. My recommended next slice: make the provider abstraction final-ish, then implement a real one-pass provider. Everything else builds cleanly on that. diff --git a/crates/lanspread-tauri-deno-ts/src/components/StateChip.tsx b/crates/lanspread-tauri-deno-ts/src/components/StateChip.tsx index 9c49213..214b563 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/StateChip.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/StateChip.tsx @@ -1,13 +1,5 @@ import { Game } from '../lib/types'; -import { deriveState } from '../lib/gameState'; - -const LABELS: Record = { - installed: 'Installed', - local: 'Local', - downloading: 'Downloading', - busy: 'Working', - none: '', -}; +import { deriveState, stateChipLabel } from '../lib/gameState'; interface Props { game: Game; @@ -17,7 +9,7 @@ interface Props { export const StateChip = ({ game, showNone = false }: Props) => { const state = deriveState(game); - const label = LABELS[state] ?? ''; + const label = stateChipLabel(game); if (!label && !showNone) return null; return (
diff --git a/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx b/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx index b5d9693..117ba36 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/modals/GameDetailModal.tsx @@ -5,7 +5,7 @@ import { StateChip } from '../StateChip'; import { ActionButton } from '../ActionButton'; import { Game, InstallStatus } from '../../lib/types'; -import { canStreamInstall, deriveState, hasNewerLocalVersion, isInProgress } from '../../lib/gameState'; +import { canStreamInstall, gameStatusLabel, hasNewerLocalVersion, isInProgress } from '../../lib/gameState'; import { formatBytes, formatEtiVersion, formatPlayers } from '../../lib/format'; interface Props { @@ -29,16 +29,6 @@ const tagsFromGame = (game: Game): string[] => { return tags; }; -const statusLabelFor = (game: Game): string => { - switch (deriveState(game)) { - case 'installed': return 'Installed'; - case 'local': return 'Downloaded'; - case 'downloading': return 'Downloading'; - case 'busy': return 'Working…'; - case 'none': return 'Not downloaded'; - } -}; - export const GameDetailModal = ({ game, thumbnailUrl, @@ -115,7 +105,7 @@ export const GameDetailModal = ({
Status
-
{statusLabelFor(game)}
+
{gameStatusLabel(game)}
diff --git a/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts b/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts index a041f19..ec7a3a2 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts @@ -82,6 +82,35 @@ export const deriveState = (game: Game): DerivedState => { return 'none'; }; +export const isInstalledNotShareable = (game: Game): boolean => + game.installed && !game.downloaded; + +export const stateChipLabel = (game: Game): string => { + const state = deriveState(game); + if (state === 'installed' && isInstalledNotShareable(game)) return 'Not shareable'; + switch (state) { + case 'installed': return 'Installed'; + case 'local': return 'Local'; + case 'downloading': return 'Downloading'; + case 'busy': return 'Working'; + case 'none': return ''; + } +}; + +export const gameStatusLabel = (game: Game): string => { + const state = deriveState(game); + if (state === 'installed' && isInstalledNotShareable(game)) { + return 'Installed, not shareable'; + } + switch (state) { + case 'installed': return 'Installed'; + case 'local': return 'Downloaded'; + case 'downloading': return 'Downloading'; + case 'busy': return 'Working…'; + case 'none': return 'Not downloaded'; + } +}; + export const isUnavailable = (game: Game): boolean => !game.installed && !game.downloaded diff --git a/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts b/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts index 2cd40ad..333f23b 100644 --- a/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts +++ b/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts @@ -11,7 +11,9 @@ import { formatDownloadEta, formatDownloadSpeed, formatDownloadSpeedShort, + gameStatusLabel, mergeGameUpdate, + stateChipLabel, } from '../src/lib/gameState.ts'; import { ActiveOperationKind, @@ -243,3 +245,42 @@ Deno.test('stream install is available only for idle remote games', () => { 'busy games should not expose streamed install', ); }); + +Deno.test('streamed local installs are labeled installed but not shareable', () => { + const streamed = game({ + downloaded: false, + installed: true, + install_status: InstallStatus.Installed, + }); + const downloadedInstall = game({ + downloaded: true, + installed: true, + install_status: InstallStatus.Installed, + }); + + assertEquals( + deriveState(streamed), + 'installed', + 'streamed local installs should keep installed visual state', + ); + assertEquals( + stateChipLabel(streamed), + 'Not shareable', + 'card chip should make the non-shareable state visible', + ); + assertEquals( + gameStatusLabel(streamed), + 'Installed, not shareable', + 'detail status should spell out installed plus non-shareable', + ); + assertEquals( + stateChipLabel(downloadedInstall), + 'Installed', + 'normal downloaded installs should keep the installed chip label', + ); + assertEquals( + gameStatusLabel(downloadedInstall), + 'Installed', + 'normal downloaded installs should keep the installed detail label', + ); +});