feat(play-next): add FRIENDS row and reason text

Shows games from followed users. Reason displays friend name and finish
or playtime status. Supports reroll without mood. Added English and
German translations.
This commit is contained in:
lobo committed 2026-10-07 09:42:10 +02:00
1 parent e041c71cf8
commit 6ae2abee07
5 files changed
+37 -7

No files matched your search

+2 -2
View File
@@ -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 <date>" 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.
+4 -1
View File
@@ -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}}"
+4 -1
View File
@@ -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}}"
+6 -1
View File
@@ -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'})});
}
+21 -2
View File
@@ -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();
});