feat(inbox): add expandable details to digest notes

Digest notes only showed counts. Now a digest note opens to list its
games and items, grouped by kind. The list loads once, from
GET /api/inbox/{id}/details. Achievements show 5 per game first.
This commit is contained in:
lobo committed 2026-10-13 09:17:41 +02:00
1 parent 600cf07b5a
commit ad6f5575d2
7 files changed
+212 -10

No files matched your search

+3 -1
View File
@@ -177,7 +177,9 @@ flowchart LR
settings page lists who you follow ("Unfollow") and who follows you ("Remove", a private follower shows as "Private
user"). Core writes one inbox note per followed user and batch (new games, achievements, status changes, wishlist
adds; what the profile hides is left out). `/inbox` (bell in the header, with the unread count) lists the newest 50 notes
and marks them read; a user who goes private ends the follow with a note. Two more notes come from the user's own
and marks them read; a digest note opens inline (click) and lists its games, achievements, status changes and wishlist
items, while you still follow the person and the part is public (5 achievements per game, then "show all"); a user
who goes private ends the follow with a note. Two more notes come from the user's own
PlayStation link (`PSN_EXPIRING` about 2 days before it ends, `PSN_EXPIRED` when it has ended), with a link to the
settings. Notes older than 90 days are deleted.
- **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it"
+8 -1
View File
@@ -969,7 +969,14 @@
"PART_STATUS_CHANGES_one": "{{count}} Statuswechsel",
"PART_STATUS_CHANGES_other": "{{count}} Statuswechsel",
"PART_WISHLIST_ADDS_one": "{{count}} Wunschlisten-Eintrag",
"PART_WISHLIST_ADDS_other": "{{count}} Wunschlisten-Einträge"
"PART_WISHLIST_ADDS_other": "{{count}} Wunschlisten-Einträge",
"DETAIL_NEW_GAME": "Neue Spiele",
"DETAIL_ACHIEVEMENT": "Erfolge und Trophäen",
"DETAIL_STATUS": "Statusänderungen",
"DETAIL_WISHLIST": "Wunschliste",
"SHOW_ALL": "Alle {{count}} anzeigen",
"DETAILS_GONE": "Keine Details mehr: du folgst dieser Person nicht mehr oder der Bereich ist jetzt privat.",
"DETAILS_ERROR": "Die Details konnten nicht geladen werden."
},
"NEWS": {
"TITLE": "Neuigkeiten",
+8 -1
View File
@@ -969,7 +969,14 @@
"PART_STATUS_CHANGES_one": "{{count}} status change",
"PART_STATUS_CHANGES_other": "{{count}} status changes",
"PART_WISHLIST_ADDS_one": "{{count}} new wishlist item",
"PART_WISHLIST_ADDS_other": "{{count}} new wishlist items"
"PART_WISHLIST_ADDS_other": "{{count}} new wishlist items",
"DETAIL_NEW_GAME": "New games",
"DETAIL_ACHIEVEMENT": "Achievements and trophies",
"DETAIL_STATUS": "Status changes",
"DETAIL_WISHLIST": "Wishlist",
"SHOW_ALL": "Show all {{count}}",
"DETAILS_GONE": "No details any more: you no longer follow this person or the part is private now.",
"DETAILS_ERROR": "The details could not be loaded."
},
"NEWS": {
"TITLE": "What's new",
+69 -3
View File
@@ -1,15 +1,16 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Link} from 'react-router-dom';
import {Loader2} from 'lucide-react';
import {ChevronDown, Loader2} from 'lucide-react';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import PageContainer from '../components/PageContainer.jsx';
import {formatDate} from '../utils/format.js';
import {digestParts, fetchInbox, markInboxRead} from '../utils/inbox.js';
import {digestParts, fetchInbox, fetchInboxDetails, groupDetails, markInboxRead} from '../utils/inbox.js';
const PSN_KINDS = ['PSN_EXPIRING', 'PSN_EXPIRED'];
const ACHIEVEMENTS_SHOWN = 5;
/**
* The newest notes about the people the user follows and about the PlayStation link. Opening the page marks them read.
@@ -18,6 +19,10 @@ export default function InboxPage() {
const {t, i18n} = useTranslation();
const [notes, setNotes] = useState([]);
const [status, setStatus] = useState('loading');
const [open, setOpen] = useState(null);
// note id -> {status: 'loading' | 'error' | 'ready', results}
const [details, setDetails] = useState({});
const [showAll, setShowAll] = useState({});
useEffect(() => {
(async () => {
@@ -42,6 +47,59 @@ export default function InboxPage() {
return t('INBOX.DIGEST', {name, parts});
};
const toggle = async (id) => {
if (open === id) {
setOpen(null);
return;
}
setOpen(id);
if (details[id]) return;
setDetails((current) => ({...current, [id]: {status: 'loading', results: []}}));
const {ok, status, data} = await fetchInboxDetails(id);
setDetails((current) => ({...current, [id]: ok || status === 404 ? {status: 'ready', results: data?.results ?? []} : {status: 'error', results: []}}));
};
const gameLines = (group, {gameName, items}, noteId) => {
if (group.kind === 'STATUS') {
return items.map((item, i) => (
<li key={`${gameName}-${i}`}>
{gameName}: {t(`BACKLOG.STATUS.${item.fromStatus}`)} → {t(`BACKLOG.STATUS.${item.toStatus}`)}
</li>
));
}
if (group.kind !== 'ACHIEVEMENT') return <li key={gameName}>{gameName}</li>;
const key = `${noteId}:${gameName}`;
const shown = showAll[key] ? items : items.slice(0, ACHIEVEMENTS_SHOWN);
return (
<li key={gameName}>
{gameName}
<ul className="ml-4 list-disc">
{shown.map((item, i) => <li key={i}>{item.itemName}</li>)}
</ul>
{!showAll[key] && items.length > ACHIEVEMENTS_SHOWN && (
<button type="button" className="ml-4 underline"
onClick={() => setShowAll((current) => ({...current, [key]: true}))}>
{t('INBOX.SHOW_ALL', {count: items.length})}
</button>
)}
</li>
);
};
const detailBlock = (noteId) => {
const entry = details[noteId];
if (!entry || entry.status === 'loading') return <Loader2 className="size-4 animate-spin text-muted-foreground"/>;
if (entry.status === 'error') return <p className="text-xs text-destructive">{t('INBOX.DETAILS_ERROR')}</p>;
const groups = groupDetails(entry.results);
if (groups.length === 0) return <p className="text-xs text-muted-foreground">{t('INBOX.DETAILS_GONE')}</p>;
return groups.map((group) => (
<div key={group.kind} className="space-y-1">
<h3 className="text-xs font-semibold uppercase text-muted-foreground">{t(`INBOX.DETAIL_${group.kind}`)}</h3>
<ul className="space-y-0.5 text-sm">{group.games.map((gameGroup) => gameLines(group, gameGroup, noteId))}</ul>
</div>
));
};
return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
<Card>
@@ -60,11 +118,19 @@ export default function InboxPage() {
{formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})}
</p>
<p className={cn('break-words text-sm', !note.read && 'font-semibold')}>
{text(note)}
{note.kind === 'DIGEST' && note.details ? (
<button type="button" aria-expanded={open === note.id}
className="inline-flex cursor-pointer items-center gap-1 break-words text-left hover:underline"
onClick={() => toggle(note.id)}>
{text(note)}
<ChevronDown className={cn('size-4 shrink-0 transition-transform', open === note.id && 'rotate-180')}/>
</button>
) : text(note)}
{PSN_KINDS.includes(note.kind) && (
<> <Link to="/settings" className="underline">{t('INBOX.OPEN_SETTINGS')}</Link></>
)}
</p>
{open === note.id && <div className="space-y-2 pt-1">{detailBlock(note.id)}</div>}
</li>
))}
</ul>
+79 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, screen, waitFor} from '@testing-library/react';
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import InboxPage from './InboxPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
@@ -67,4 +67,82 @@ describe('InboxPage', () => {
expect(await screen.findByText('INBOX.LOAD_ERROR')).toBeTruthy();
expect(putCalls()).toEqual([]);
});
describe('details', () => {
const row = (kind, gameName, extra) => ({kind, gameName, itemName: null, fromStatus: null, toStatus: null, ...extra});
const detailCalls = () => fetch.mock.calls.filter(([url]) => String(url).includes('/details'));
const setup = (results, extra) => {
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: true, ...extra})], unread: 0}),
'GET /api/inbox/1/details': json(200, {results})});
renderWithProviders(<InboxPage/>);
};
it('opensANoteOnceShowsTheRowsGroupedAndClosesWithoutANewRequest', async () => {
setup([row('NEW_GAME', 'Alpha'), row('ACHIEVEMENT', 'Alpha', {itemName: 'Win'}),
row('STATUS', 'Beta', {fromStatus: 'BACKLOG', toStatus: 'PLAYING'})]);
const button = await screen.findByRole('button', {name: 'INBOX.DIGEST'});
expect(button.getAttribute('aria-expanded')).toBe('false');
fireEvent.click(button);
expect(await screen.findByText('INBOX.DETAIL_ACHIEVEMENT')).toBeTruthy();
expect(screen.getByText('Win')).toBeTruthy();
expect(screen.getByText(/Beta: BACKLOG\.STATUS\.BACKLOG → BACKLOG\.STATUS\.PLAYING/)).toBeTruthy();
expect(detailCalls()).toHaveLength(1);
fireEvent.click(button);
expect(screen.queryByText('INBOX.DETAIL_ACHIEVEMENT')).toBeNull();
fireEvent.click(button);
expect(await screen.findByText('INBOX.DETAIL_ACHIEVEMENT')).toBeTruthy();
expect(detailCalls()).toHaveLength(1);
});
it('givesANoteWithoutDetailsNoButtonAndNoRequest', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: false})], unread: 0})});
renderWithProviders(<InboxPage/>);
expect(await screen.findByText('INBOX.DIGEST')).toBeTruthy();
expect(screen.queryByRole('button')).toBeNull();
expect(detailCalls()).toEqual([]);
});
it('saysSoWhenTheResultIsEmpty', async () => {
setup([]);
fireEvent.click(await screen.findByRole('button', {name: 'INBOX.DIGEST'}));
expect(await screen.findByText('INBOX.DETAILS_GONE')).toBeTruthy();
});
it('saysSoWhenTheNoteIsGone', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: true})], unread: 0}),
'GET /api/inbox/1/details': json(404, {})});
renderWithProviders(<InboxPage/>);
fireEvent.click(await screen.findByRole('button', {name: 'INBOX.DIGEST'}));
expect(await screen.findByText('INBOX.DETAILS_GONE')).toBeTruthy();
});
it('showsAnErrorWhenTheDetailsFail', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: true})], unread: 0}),
'GET /api/inbox/1/details': json(500, {})});
renderWithProviders(<InboxPage/>);
fireEvent.click(await screen.findByRole('button', {name: 'INBOX.DIGEST'}));
expect(await screen.findByText('INBOX.DETAILS_ERROR')).toBeTruthy();
});
it('showsFiveAchievementsOfAGameAndTheRestOnShowAll', async () => {
setup(Array.from({length: 6}, (_, i) => row('ACHIEVEMENT', 'Alpha', {itemName: `ach${i}`})));
fireEvent.click(await screen.findByRole('button', {name: 'INBOX.DIGEST'}));
expect(await screen.findByText('ach4')).toBeTruthy();
expect(screen.queryByText('ach5')).toBeNull();
fireEvent.click(screen.getByRole('button', {name: 'INBOX.SHOW_ALL:6'}));
expect(screen.getByText('ach5')).toBeTruthy();
});
});
});
+25 -2
View File
@@ -4,11 +4,18 @@ const INBOX = () => `${import.meta.env.VITE_CORE_API_URL}/api/inbox`;
/**
* GET /api/inbox: `{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt,
* read}], unread}`, the newest 50 notes. `kind` is `DIGEST`, `VISIBILITY`, `PSN_EXPIRING` or `PSN_EXPIRED` (the PSN
* kinds have no `displayName`); `unread` counts all unread notes.
* read, details}], unread}`, the newest 50 notes. `kind` is `DIGEST`, `VISIBILITY`, `PSN_EXPIRING` or `PSN_EXPIRED`
* (the PSN kinds have no `displayName`); `unread` counts all unread notes. `details` is true when the note can be
* opened (`fetchInboxDetails`): it has detail rows and the followed user is still followed and public.
*/
export const fetchInbox = () => request(INBOX());
/**
* GET /api/inbox/{id}/details: `{results: [{kind, gameName, itemName, fromStatus, toStatus}]}`; 404 for a note that is
* not the caller's.
*/
export const fetchInboxDetails = (id) => request(`${INBOX()}/${id}/details`);
/** All notes count as read. 204. */
export const markInboxRead = () => request(`${INBOX()}/read`, {method: 'PUT'});
@@ -21,3 +28,19 @@ const PARTS = [['NEW_GAMES', 'newGames'], ['ACHIEVEMENTS', 'achievements'], ['ST
*/
export const digestParts = (note) =>
PARTS.filter(([, field]) => note[field] > 0).map(([key, field]) => ({key, count: note[field]}));
const DETAIL_KINDS = ['NEW_GAME', 'ACHIEVEMENT', 'STATUS', 'WISHLIST'];
/**
* Groups details for the page: `[{kind, games: [{gameName, items: [detail]}]}]` in the fixed kind order, only kinds
* with rows. Games keep the order of their first row.
*/
export const groupDetails = (details) =>
DETAIL_KINDS.map((kind) => {
const games = new Map();
details.filter((detail) => detail.kind === kind).forEach((detail) => {
if (!games.has(detail.gameName)) games.set(detail.gameName, {gameName: detail.gameName, items: []});
games.get(detail.gameName).items.push(detail);
});
return {kind, games: [...games.values()]};
}).filter((group) => group.games.length > 0);
+20 -1
View File
@@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest';
import {digestParts} from './inbox.js';
import {digestParts, groupDetails} from './inbox.js';
describe('digestParts', () => {
it('keepsOnlyTheNonZeroCountsInAFixedOrder', () => {
@@ -13,3 +13,22 @@ describe('digestParts', () => {
expect(digestParts({newGames: 0, achievements: 0, statusChanges: 0, wishlistAdds: 0})).toEqual([]);
});
});
describe('groupDetails', () => {
const row = (kind, gameName, extra) => ({kind, gameName, itemName: null, fromStatus: null, toStatus: null, ...extra});
it('putsTheKindsInAFixedOrderGroupsPerGameAndLeavesEmptyKindsOut', () => {
const details = [row('WISHLIST', 'W'), row('ACHIEVEMENT', 'A', {itemName: 'one'}), row('NEW_GAME', 'A'),
row('ACHIEVEMENT', 'B', {itemName: 'x'}), row('ACHIEVEMENT', 'A', {itemName: 'two'})];
const groups = groupDetails(details);
expect(groups.map((group) => group.kind)).toEqual(['NEW_GAME', 'ACHIEVEMENT', 'WISHLIST']);
expect(groups[1].games.map((game) => game.gameName)).toEqual(['A', 'B']);
expect(groups[1].games[0].items.map((item) => item.itemName)).toEqual(['one', 'two']);
});
it('isEmptyForNoDetails', () => {
expect(groupDetails([])).toEqual([]);
});
});