diff --git a/README.md b/README.md index b0f55a4..37d6868 100644 --- a/README.md +++ b/README.md @@ -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" diff --git a/public/i18n/de.json b/public/i18n/de.json index 155187f..9bec86f 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -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", diff --git a/public/i18n/en.json b/public/i18n/en.json index be15036..8f59286 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -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", diff --git a/src/pages/InboxPage.jsx b/src/pages/InboxPage.jsx index 84f3c00..1fa99b7 100644 --- a/src/pages/InboxPage.jsx +++ b/src/pages/InboxPage.jsx @@ -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) => ( +
  • + {gameName}: {t(`BACKLOG.STATUS.${item.fromStatus}`)} → {t(`BACKLOG.STATUS.${item.toStatus}`)} +
  • + )); + } + if (group.kind !== 'ACHIEVEMENT') return
  • {gameName}
  • ; + const key = `${noteId}:${gameName}`; + const shown = showAll[key] ? items : items.slice(0, ACHIEVEMENTS_SHOWN); + return ( +
  • + {gameName} + + {!showAll[key] && items.length > ACHIEVEMENTS_SHOWN && ( + + )} +
  • + ); + }; + + const detailBlock = (noteId) => { + const entry = details[noteId]; + if (!entry || entry.status === 'loading') return ; + if (entry.status === 'error') return

    {t('INBOX.DETAILS_ERROR')}

    ; + const groups = groupDetails(entry.results); + if (groups.length === 0) return

    {t('INBOX.DETAILS_GONE')}

    ; + return groups.map((group) => ( +
    +

    {t(`INBOX.DETAIL_${group.kind}`)}

    + +
    + )); + }; + return ( @@ -60,11 +118,19 @@ export default function InboxPage() { {formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})}

    - {text(note)} + {note.kind === 'DIGEST' && note.details ? ( + + ) : text(note)} {PSN_KINDS.includes(note.kind) && ( <> {t('INBOX.OPEN_SETTINGS')} )}

    + {open === note.id &&
    {detailBlock(note.id)}
    } ))} diff --git a/src/pages/InboxPage.test.jsx b/src/pages/InboxPage.test.jsx index 8fd3879..265d004 100644 --- a/src/pages/InboxPage.test.jsx +++ b/src/pages/InboxPage.test.jsx @@ -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(); + }; + + 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(); + + 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(); + + 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(); + + 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(); + }); + }); }); diff --git a/src/utils/inbox.js b/src/utils/inbox.js index 0e05277..991943a 100644 --- a/src/utils/inbox.js +++ b/src/utils/inbox.js @@ -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); diff --git a/src/utils/inbox.test.js b/src/utils/inbox.test.js index ed9e2d5..35c5075 100644 --- a/src/utils/inbox.test.js +++ b/src/utils/inbox.test.js @@ -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([]); + }); +});