diff --git a/README.md b/README.md index 54ed9c6..4b83e4b 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,9 @@ To Be Played is split into three repositories: - **Coming games**: a game that is not released yet (and nobody played) shows a "Coming " badge in the library, on the details page, in the stat game lists and on the public profile. The pile counts only the games Core sends in `pileGames`, so coming games are not on it; Core never picks them. -- **What to play next** (`/play-next`): six rows of up to 3 backlog games each: up next (your own backlog order), +- **What to play next** (`/play-next`): seven rows of up to 3 backlog games each: up next (your own backlog order), next in series (the next part of a series you played), because you played (a similar game for each of your 3 most - played), same studio (a developer you played), longest on your list, and mood picks. A time + played), same studio (a developer you played), from people you follow (a backlog game someone you follow finished or played 5 h or more), longest on your list, and mood picks. A time budget (up to 5 / 15 / 40 h or any) filters all rows; a mood (story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com) only the mood row. Each row has its own reroll without repeats, and "Start playing" in one click. On a phone each row scrolls sideways. diff --git a/public/i18n/de.json b/public/i18n/de.json index 3bcf9d5..5ddc77a 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -431,11 +431,13 @@ }, "PLAY_NEXT": { "TITLE": "Was spiele ich als Nächstes?", - "SUBTITLE": "Spiele aus deinem Backlog in sechs Reihen: als Nächstes in deiner Reihenfolge, der nächste Teil einer Reihe, die du gespielt hast, wie Spiele, die du viel gespielt hast, von einem Studio, das du gespielt hast, am längsten wartend und nach Stimmung. Wähle eine Dauer für alle Reihen und eine Stimmung für die letzte.", + "SUBTITLE": "Spiele aus deinem Backlog in sieben Reihen: als Nächstes in deiner Reihenfolge, der nächste Teil einer Reihe, die du gespielt hast, wie Spiele, die du viel gespielt hast, von einem Studio, das du gespielt hast, was Leute spielten, denen du folgst, am längsten wartend und nach Stimmung. Wähle eine Dauer für alle Reihen und eine Stimmung für die letzte.", "REASON": { "SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})", "SERIES": "Reihe: {{name}}", "STUDIO": "Von {{name}}", + "FRIENDS_FINISHED": "{{name}} hat es durchgespielt", + "FRIENDS_PLAYED": "{{name}} hat {{playtime}} gespielt", "LONGEST": "Auf deiner Liste seit {{date}}" }, "CATEGORY": { @@ -443,6 +445,7 @@ "SERIES": "Nächster Teil der Reihe", "SIMILAR": "Weil du gespielt hast", "STUDIO": "Vom selben Studio", + "FRIENDS": "Von Leuten, denen du folgst", "LONGEST": "Am längsten auf deiner Liste", "RANDOM": "Zufallstreffer", "MOOD": "Stimmung: {{mood}}" diff --git a/public/i18n/en.json b/public/i18n/en.json index cbe8b40..02d926a 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -431,11 +431,13 @@ }, "PLAY_NEXT": { "TITLE": "What to play next", - "SUBTITLE": "Games from your backlog in six rows: next in your order, the next part of a series you played, like games you played a lot, from a studio you played, waiting longest, and by mood. Pick a length for all rows and a mood for the last one.", + "SUBTITLE": "Games from your backlog in seven rows: next in your order, the next part of a series you played, like games you played a lot, from a studio you played, what people you follow played, waiting longest, and by mood. Pick a length for all rows and a mood for the last one.", "REASON": { "SIMILAR": "Because you played {{name}} ({{playtime}})", "SERIES": "Series: {{name}}", "STUDIO": "From {{name}}", + "FRIENDS_FINISHED": "{{name}} finished it", + "FRIENDS_PLAYED": "{{name}} played {{playtime}}", "LONGEST": "On your list since {{date}}" }, "CATEGORY": { @@ -443,6 +445,7 @@ "SERIES": "Next in series", "SIMILAR": "Because you played", "STUDIO": "Same studio", + "FRIENDS": "From people you follow", "LONGEST": "Longest on your list", "RANDOM": "Random picks", "MOOD": "Mood: {{mood}}" diff --git a/src/pages/PlayNextPage.jsx b/src/pages/PlayNextPage.jsx index 3c1ae1d..b3be395 100644 --- a/src/pages/PlayNextPage.jsx +++ b/src/pages/PlayNextPage.jsx @@ -27,7 +27,7 @@ const BUDGETS = [ // games without that data. const MOODS = ['ANY', 'STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY', 'SPEEDRUN']; // The rows in the order Core sends them; RANDOM is the mood row. -const ROW_ORDER = ['NEXT_UP', 'SERIES', 'SIMILAR', 'STUDIO', 'LONGEST', 'RANDOM']; +const ROW_ORDER = ['NEXT_UP', 'SERIES', 'SIMILAR', 'STUDIO', 'FRIENDS', 'LONGEST', 'RANDOM']; // The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back. const MAX_EXCLUDES = 100; const ROW_CLASS = 'flex gap-4 overflow-x-auto snap-x snap-mandatory pb-2 sm:grid sm:grid-cols-3 sm:overflow-visible'; @@ -259,6 +259,11 @@ const reasonText = (reason, t, language) => { if (reason?.source === 'STUDIO') { return t('PLAY_NEXT.REASON.STUDIO', {name: reason.studioName}); } + if (reason?.source === 'FRIENDS') { + return reason.friendFinished + ? t('PLAY_NEXT.REASON.FRIENDS_FINISHED', {name: reason.friendName}) + : t('PLAY_NEXT.REASON.FRIENDS_PLAYED', {name: reason.friendName, playtime: formatPlaytime(reason.friendPlaytimeMinutes)}); + } if (reason?.source === 'LONGEST') { return t('PLAY_NEXT.REASON.LONGEST', {date: formatDate(reason.firstSeenAt, language, {dateStyle: 'medium'})}); } diff --git a/src/pages/PlayNextPage.test.jsx b/src/pages/PlayNextPage.test.jsx index 2fa3f0a..8013753 100644 --- a/src/pages/PlayNextPage.test.jsx +++ b/src/pages/PlayNextPage.test.jsx @@ -21,6 +21,9 @@ const ALL = () => [ row('SERIES', game(6, {reason: {source: 'SERIES', seriesName: 'Mass Effect'}})), row('SIMILAR', game(3, {reason: {source: 'SIMILAR', anchorAppid: 9, anchorName: 'Celeste', anchorPlaytimeMinutes: 600}})), row('STUDIO', game(8, {reason: {source: 'STUDIO', studioName: 'BioWare'}})), + row('FRIENDS', + game(11, {reason: {source: 'FRIENDS', friendName: 'Max', friendFinished: true, friendPlaytimeMinutes: null}}), + game(12, {reason: {source: 'FRIENDS', friendName: 'Anna', friendFinished: false, friendPlaytimeMinutes: 2400}})), row('LONGEST', game(4, {reason: {source: 'LONGEST', firstSeenAt: '2024-03-12T10:00:00Z'}})), row('RANDOM', game(5, {reason: {source: 'RANDOM'}})), ]; @@ -78,7 +81,7 @@ describe('PlayNextPage', () => { await screen.findByText('Game 1'); expect(screen.getAllByRole('heading', {level: 2}).map((h) => h.textContent)).toEqual([ 'PLAY_NEXT.CATEGORY.NEXT_UP', 'PLAY_NEXT.CATEGORY.SERIES', 'PLAY_NEXT.CATEGORY.SIMILAR', - 'PLAY_NEXT.CATEGORY.STUDIO', 'PLAY_NEXT.CATEGORY.LONGEST', 'PLAY_NEXT.CATEGORY.RANDOM']); + 'PLAY_NEXT.CATEGORY.STUDIO', 'PLAY_NEXT.CATEGORY.FRIENDS', 'PLAY_NEXT.CATEGORY.LONGEST', 'PLAY_NEXT.CATEGORY.RANDOM']); const params = pickerUrls()[0]; expect(params.has('count')).toBe(false); expect(params.has('category')).toBe(false); @@ -191,6 +194,20 @@ describe('PlayNextPage', () => { expect(pickerUrls()[2].has('mood')).toBe(false); }); + it('rerolls the friends row with category=FRIENDS and without a mood', async () => { + mockRows(ALL(), {FRIENDS: [row('FRIENDS', game(13))]}); + renderPage(); + await screen.findByText('Game 1'); + fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.STORY'})); + await screen.findByText('Game 5'); + + fireEvent.click(rerollOf('PLAY_NEXT.CATEGORY.FRIENDS')); + + await screen.findByText('Game 13'); + expect(pickerUrls()[2].get('category')).toBe('FRIENDS'); + expect(pickerUrls()[2].has('mood')).toBe(false); + }); + it('starts a row over when it has no unseen game left', async () => { mockBackend((params) => { if (!params.get('category')) return ALL(); @@ -244,7 +261,7 @@ describe('PlayNextPage', () => { expect(await screen.findByText('PLAY_NEXT.EMPTY_MOOD')).toBeTruthy(); }); - it('shows a reason line for the series, similar, studio and longest rows only', async () => { + it('shows a reason line for the series, similar, studio, friends and longest rows only', async () => { mockRows(); renderPage(); @@ -252,6 +269,8 @@ describe('PlayNextPage', () => { expect(await screen.findByText('PLAY_NEXT.REASON.SIMILAR')).toBeTruthy(); expect(screen.getByText('PLAY_NEXT.REASON.SERIES')).toBeTruthy(); expect(screen.getByText('PLAY_NEXT.REASON.STUDIO')).toBeTruthy(); + expect(screen.getByText('PLAY_NEXT.REASON.FRIENDS_FINISHED')).toBeTruthy(); + expect(screen.getByText('PLAY_NEXT.REASON.FRIENDS_PLAYED')).toBeTruthy(); expect(screen.getByText('PLAY_NEXT.REASON.LONGEST')).toBeTruthy(); expect(screen.queryByText(/PLAY_NEXT\.REASON\.(RANDOM|NEXT_UP)/)).toBeNull(); });