Public Access
feat(play-next): show why each game was picked
Each card shows a reason line under the game name. Issue #28.
This commit is contained in:
1 parent
2b20b389b7
commit
edeea5a43d
5 files changed
+55
-5
No files matched your search
@@ -26,8 +26,8 @@ To Be Played is split into three repositories:
|
|||||||
Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked
|
Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked
|
||||||
total, perfect games, average completion and the 5 games closest to 100 %.
|
total, perfect games, average completion and the 5 games closest to 100 %.
|
||||||
- **What to play next** (`/play-next`): three backlog picks for a time budget (up to 5 / 15 / 40 h or any) and a mood
|
- **What to play next** (`/play-next`): three backlog picks for a time budget (up to 5 / 15 / 40 h or any) and a mood
|
||||||
(story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com), high priority first, reroll without
|
(story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com). Each pick says why: like a game you
|
||||||
repeats, and "Start playing" in one click.
|
played a lot, waiting longest, or random. Reroll without repeats, and "Start playing" in one click.
|
||||||
- **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in
|
- **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in
|
||||||
years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods;
|
years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods;
|
||||||
trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
|
trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
|
||||||
|
|||||||
+6
-1
@@ -409,7 +409,12 @@
|
|||||||
},
|
},
|
||||||
"PLAY_NEXT": {
|
"PLAY_NEXT": {
|
||||||
"TITLE": "Was spiele ich als Nächstes?",
|
"TITLE": "Was spiele ich als Nächstes?",
|
||||||
"SUBTITLE": "Drei Spiele aus deinem Backlog, hohe Priorität zuerst. Wähle eine Länge und eine Stimmung.",
|
"SUBTITLE": "Drei Spiele aus deinem Backlog: eins wie ein Spiel, das du viel gespielt hast, das am längsten wartende und eins zufällig. Wähle eine Länge und eine Stimmung.",
|
||||||
|
"REASON": {
|
||||||
|
"SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})",
|
||||||
|
"LONGEST": "Auf deiner Liste seit {{date}}",
|
||||||
|
"RANDOM": "Zufallstreffer"
|
||||||
|
},
|
||||||
"BUDGET_LABEL": "Spieldauer",
|
"BUDGET_LABEL": "Spieldauer",
|
||||||
"BUDGET": {
|
"BUDGET": {
|
||||||
"SHORT": "Bis 5 h",
|
"SHORT": "Bis 5 h",
|
||||||
|
|||||||
+6
-1
@@ -409,7 +409,12 @@
|
|||||||
},
|
},
|
||||||
"PLAY_NEXT": {
|
"PLAY_NEXT": {
|
||||||
"TITLE": "What to play next",
|
"TITLE": "What to play next",
|
||||||
"SUBTITLE": "Three games from your backlog, high priority first. Pick a length and a mood.",
|
"SUBTITLE": "Three games from your backlog: one like a game you played a lot, the one waiting longest, and one at random. Pick a length and a mood.",
|
||||||
|
"REASON": {
|
||||||
|
"SIMILAR": "Because you played {{name}} ({{playtime}})",
|
||||||
|
"LONGEST": "On your list since {{date}}",
|
||||||
|
"RANDOM": "Random pick"
|
||||||
|
},
|
||||||
"BUDGET_LABEL": "Time to beat",
|
"BUDGET_LABEL": "Time to beat",
|
||||||
"BUDGET": {
|
"BUDGET": {
|
||||||
"SHORT": "Up to 5 h",
|
"SHORT": "Up to 5 h",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import GameCover from '../components/GameCover.jsx';
|
|||||||
import FitText from '../components/FitText.jsx';
|
import FitText from '../components/FitText.jsx';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import {saveGameStatus} from '../utils/backlog.js';
|
import {saveGameStatus} from '../utils/backlog.js';
|
||||||
|
import {formatDate} from '../utils/format.js';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
import {request} from '../utils/api.js';
|
import {request} from '../utils/api.js';
|
||||||
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||||
@@ -174,6 +175,20 @@ export default function PlayNextPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reasonText = (reason, t, language) => {
|
||||||
|
if (!reason) return null;
|
||||||
|
if (reason.source === 'SIMILAR') {
|
||||||
|
return t('PLAY_NEXT.REASON.SIMILAR', {
|
||||||
|
name: reason.anchorName ?? `App ${reason.anchorAppid}`,
|
||||||
|
playtime: formatPlaytime(reason.anchorPlaytimeMinutes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (reason.source === 'LONGEST') {
|
||||||
|
return t('PLAY_NEXT.REASON.LONGEST', {date: formatDate(reason.firstSeenAt, language, {dateStyle: 'medium'})});
|
||||||
|
}
|
||||||
|
return t('PLAY_NEXT.REASON.RANDOM');
|
||||||
|
};
|
||||||
|
|
||||||
const emptyKey = (budget, mood) => {
|
const emptyKey = (budget, mood) => {
|
||||||
// With a length limit, waiting for the background check may never help.
|
// With a length limit, waiting for the background check may never help.
|
||||||
if (mood === 'SPEEDRUN' && !budget.maxMinutes) return 'PLAY_NEXT.EMPTY_SPEEDRUN';
|
if (mood === 'SPEEDRUN' && !budget.maxMinutes) return 'PLAY_NEXT.EMPTY_SPEEDRUN';
|
||||||
@@ -182,9 +197,10 @@ const emptyKey = (budget, mood) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function PickCard({game}) {
|
function PickCard({game}) {
|
||||||
const {t} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [started, setStarted] = useState(false);
|
const [started, setStarted] = useState(false);
|
||||||
|
const reason = reasonText(game.reason, t, i18n.language);
|
||||||
|
|
||||||
const startPlaying = async () => {
|
const startPlaying = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -214,6 +230,7 @@ function PickCard({game}) {
|
|||||||
<Link to={`/library/${game.appid}`} className="@container block min-w-0 font-semibold hover:text-primary transition-colors">
|
<Link to={`/library/${game.appid}`} className="@container block min-w-0 font-semibold hover:text-primary transition-colors">
|
||||||
<FitText text={game.name ?? `App ${game.appid}`}/>
|
<FitText text={game.name ?? `App ${game.appid}`}/>
|
||||||
</Link>
|
</Link>
|
||||||
|
{reason && <p className="text-sm text-muted-foreground">{reason}</p>}
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
{game.timeToBeatMinutes > 0 && (
|
{game.timeToBeatMinutes > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -224,4 +224,27 @@ describe('PlayNextPage', () => {
|
|||||||
|
|
||||||
expect(screen.queryByRole('button', {name: 'PLAY_NEXT.REROLL'})).not.toBeNull();
|
expect(screen.queryByRole('button', {name: 'PLAY_NEXT.REROLL'})).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows why each game was picked', async () => {
|
||||||
|
mockBackend([[
|
||||||
|
game(1, {reason: {source: 'SIMILAR', anchorAppid: 9, anchorName: 'Celeste', anchorPlaytimeMinutes: 600}}),
|
||||||
|
game(2, {reason: {source: 'LONGEST', firstSeenAt: '2024-03-12T10:00:00Z'}}),
|
||||||
|
game(3, {reason: {source: 'RANDOM'}}),
|
||||||
|
]]);
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('PLAY_NEXT.REASON.SIMILAR')).toBeTruthy();
|
||||||
|
expect(screen.getByText('PLAY_NEXT.REASON.LONGEST')).toBeTruthy();
|
||||||
|
expect(screen.getByText('PLAY_NEXT.REASON.RANDOM')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no reason line for a game without a reason', async () => {
|
||||||
|
mockBackend([[game(1)]]);
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
expect(screen.queryByText(/PLAY_NEXT\.REASON/)).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user