Compare commits

...
4 Commits
Author SHA1 Message Date
lobo ec0b2844c3 test: use fake steam id in test fixtures
Build, Test & Push Frontend / docker (push) Successful in 25s
Build, Test & Push Frontend / quality-check (push) Successful in 37s
Replace the real Steam ID with the fake 76561198000000001 in the
SettingsPage and validation tests. Keep personal data out of the repo.
2026-10-13 12:03:56 +02:00
lobo c599054059 docs(readme): replace screenshots with neutral demo data
Old screenshots showed a real account. Retake them from the local
stack with 30 demo games and persona "Dev". Add a dashboard shot and
docs/screenshots/README.md.
Refs Lobo_Corp/ToBePlayed-Deployment#12
2026-10-13 12:03:56 +02:00
lobo ad6f5575d2 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.
2026-10-13 09:17:41 +02:00
lobo 600cf07b5a test(game-detail): wait for points refetch instead of fixed sleep 2026-10-13 09:17:41 +02:00
18 changed files with 290 additions and 21 deletions

No files matched your search

+7 -5
View File
@@ -5,9 +5,9 @@ you finished. The UI is in German and English, with light and dark mode.
![Library](docs/screenshots/library-desktop-dark.png) ![Library](docs/screenshots/library-desktop-dark.png)
| Game detail | Public profile | Mobile | | Dashboard | Game detail | Public profile | Mobile |
|---|---|---| |---|---|---|---|
| ![Game detail](docs/screenshots/detail-desktop-dark.png) | ![Public profile](docs/screenshots/profile-desktop-dark.png) | ![Mobile library](docs/screenshots/library-mobile-dark.png) | | ![Dashboard](docs/screenshots/dashboard-desktop-dark.png) | ![Game detail](docs/screenshots/detail-desktop-dark.png) | ![Public profile](docs/screenshots/profile-desktop-dark.png) | ![Mobile library](docs/screenshots/library-mobile-dark.png) |
To Be Played is split into three repositories: To Be Played is split into three repositories:
@@ -177,7 +177,9 @@ flowchart LR
settings page lists who you follow ("Unfollow") and who follows you ("Remove", a private follower shows as "Private 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 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 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 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. 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" - **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it"
@@ -363,7 +365,7 @@ other, then start them in this order (Core also starts without Auth and finds it
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173` 3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173`
(`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development (`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development
values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`. values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`.
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 8 4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 30
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers. games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
The dev user is `PLATFORM_ADMIN`, so user, auditor and admin pages can all be tried ("View as" in the menu The dev user is `PLATFORM_ADMIN`, so user, auditor and admin pages can all be tried ("View as" in the menu
+68
View File
@@ -0,0 +1,68 @@
# README screenshots
The screenshots in the main README show a neutral demo library (30 games, persona "Dev"), never real user data.
They come from the local stack with the WireMock mocks of `ToBePlayed-Core/local/wiremock`.
## Start
1. Start the stack as in the main README, section "Local development without keys".
2. Open the app in a desktop browser, log in with `dev@localhost` / `password123`, pass the welcome page.
3. Settings: dark mode on, language English, public profile on.
4. Dashboard: "Sync now". Wait about one minute: the achievements load one Steam call per second.
5. Set three things by hand (the sync cannot):
- No Man's Sky: status Dropped.
- Slay the Spire: "Set to 100%".
- Celeste: rating 4 stars, note "Chapter 7 next."
6. Reload the dashboard and close the "games imported" banner.
## Capture
Set the viewport (browser DevTools device toolbar, or Playwright) to the size in the table. Take a viewport PNG, no
scaling, not full page.
| File | Viewport | Page and state |
|---|---|---|
| `library-desktop-dark.png` | 1280x1022 | `/library`, filter All, sort "Most played", 20 per page |
| `detail-desktop-dark.png` | 1280x1414 | `/library/504230` (Celeste) |
| `dashboard-desktop-dark.png` | 1280x1100 | `/dashboard`, banner closed, mouse away from the cards |
| `library-mobile-dark.png` | 390x1036 | `/library`, same state as desktop |
| `profile-desktop-dark.png` | 1280x1288 | log out, then `/u/Dev` |
## From the Claude box
The box is itself a container, so the stack differs from the main README. Traps first: the WireMock `-v` path is
resolved on the Docker host (the mock then loads 0 mappings), and the box reaches sibling containers only through
the shared network, not through `localhost`.
```
docker run -d --name tbp_auth_db_local -e POSTGRES_DB=tbp_auth -e POSTGRES_USER=tbp_user -e POSTGRES_PASSWORD=local_secret_password postgres:17-alpine
docker run -d --name tbp_core_db_local -e POSTGRES_DB=tbp_core -e POSTGRES_USER=tbp_user -e POSTGRES_PASSWORD=local_secret_password postgres:17-alpine
docker run -d --name tbp_core_mocks_local wiremock/wiremock:3.9.1
for c in tbp_auth_db_local tbp_core_db_local tbp_core_mocks_local; do docker network connect ai_default $c; done
docker cp /workspace/private/tbp/ToBePlayed-Core/local/wiremock/. tbp_core_mocks_local:/home/wiremock/
curl -X POST tbp_core_mocks_local:8080/__admin/mappings/reset
```
Repeat the `docker cp` and the reset after every mock change. Run the built jars (`target/`) and Vite:
- Auth: `java -jar target/auth-service-0.0.1-SNAPSHOT.jar --spring.profiles.active=local --spring.docker.compose.enabled=false`
plus `--spring.datasource.url=jdbc:postgresql://tbp_auth_db_local:5432/tbp_auth`, `--spring.datasource.username=tbp_user`
and `--spring.datasource.password=local_secret_password`.
- Core: the same with `tbp_core_db_local` and `tbp_core`, plus `--app.steam.base-url=$M/steam`,
`--app.igdb.base-url=$M/igdb`, `--app.igdb.token-base-url=$M/twitch`, `--app.psn.auth-base-url=$M/psn/auth`,
`--app.psn.api-base-url=$M/psn` and `--app.speedrun.base-url=$M/speedrun`. `M` is `http://<mock container IP>:8080`
(`docker inspect tbp_core_mocks_local`): Java rejects the underscore in the container name.
- Frontend: `VITE_API_URL=http://localhost:8080 VITE_CORE_API_URL=http://localhost:8090 npx vite --port 5173`.
When done, stop the three processes and `docker rm -f` the three containers.
## Rules
- Only the demo persona may appear: name "Dev", Steam ID `76561198000000001`, the default avatar.
- Keep the file names. Keep each file below about 500 KB.
- Look at every image before you commit it.
## Data
The 30 games, their achievements and the profile are in `ToBePlayed-Core/local/wiremock`.
`LocalMocksParseTest` checks that the files fit the Core types and that every achievement stub belongs to an owned game.
Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 330 KiB

After

Width:  |  Height:  |  Size: 458 KiB

+8 -1
View File
@@ -969,7 +969,14 @@
"PART_STATUS_CHANGES_one": "{{count}} Statuswechsel", "PART_STATUS_CHANGES_one": "{{count}} Statuswechsel",
"PART_STATUS_CHANGES_other": "{{count}} Statuswechsel", "PART_STATUS_CHANGES_other": "{{count}} Statuswechsel",
"PART_WISHLIST_ADDS_one": "{{count}} Wunschlisten-Eintrag", "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": { "NEWS": {
"TITLE": "Neuigkeiten", "TITLE": "Neuigkeiten",
+8 -1
View File
@@ -969,7 +969,14 @@
"PART_STATUS_CHANGES_one": "{{count}} status change", "PART_STATUS_CHANGES_one": "{{count}} status change",
"PART_STATUS_CHANGES_other": "{{count}} status changes", "PART_STATUS_CHANGES_other": "{{count}} status changes",
"PART_WISHLIST_ADDS_one": "{{count}} new wishlist item", "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": { "NEWS": {
"TITLE": "What's new", "TITLE": "What's new",
+1 -2
View File
@@ -38,6 +38,5 @@ it('checksThePointsAgainAfterTheAchievementRefresh', async () => {
// The refresh stored the ending achievement: Core would settle the finish now (the old single call did). // The refresh stored the ending achievement: Core would settle the finish now (the old single call did).
finishRefresh(json(200, {...game, achievementsStale: false, achievementsUnlocked: 5})); finishRefresh(json(200, {...game, achievementsStale: false, achievementsUnlocked: 5}));
await new Promise((resolve) => setTimeout(resolve, 50)); await waitFor(() => expect(pointsCalls()).toBe(2));
expect(pointsCalls()).toBe(2);
}); });
+69 -3
View File
@@ -1,15 +1,16 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {Link} from 'react-router-dom'; 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 {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import {formatDate} from '../utils/format.js'; 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 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. * 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 {t, i18n} = useTranslation();
const [notes, setNotes] = useState([]); const [notes, setNotes] = useState([]);
const [status, setStatus] = useState('loading'); 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(() => { useEffect(() => {
(async () => { (async () => {
@@ -42,6 +47,59 @@ export default function InboxPage() {
return t('INBOX.DIGEST', {name, parts}); 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 ( return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8"> <PageContainer className="space-y-4 px-4 py-6 md:px-8">
<Card> <Card>
@@ -60,11 +118,19 @@ export default function InboxPage() {
{formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})} {formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})}
</p> </p>
<p className={cn('break-words text-sm', !note.read && 'font-semibold')}> <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) && ( {PSN_KINDS.includes(note.kind) && (
<> <Link to="/settings" className="underline">{t('INBOX.OPEN_SETTINGS')}</Link></> <> <Link to="/settings" className="underline">{t('INBOX.OPEN_SETTINGS')}</Link></>
)} )}
</p> </p>
{open === note.id && <div className="space-y-2 pt-1">{detailBlock(note.id)}</div>}
</li> </li>
))} ))}
</ul> </ul>
+79 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest'; 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 InboxPage from './InboxPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx'; import {renderWithProviders} from '../test/renderWithProviders.jsx';
@@ -67,4 +67,82 @@ describe('InboxPage', () => {
expect(await screen.findByText('INBOX.LOAD_ERROR')).toBeTruthy(); expect(await screen.findByText('INBOX.LOAD_ERROR')).toBeTruthy();
expect(putCalls()).toEqual([]); 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();
});
});
}); });
+1 -1
View File
@@ -16,7 +16,7 @@ vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn(), info: vi.fn(
const STEAM_URL = 'https://steamcommunity.com/openid/login?openid.mode=checkid_setup'; const STEAM_URL = 'https://steamcommunity.com/openid/login?openid.mode=checkid_setup';
const profile = (overrides = {}) => ({ const profile = (overrides = {}) => ({
id: 1, email: 'a@b.de', role: 'USER', displayName: 'Lobo', steamId: '76561198005679788', steamIdVerified: false, id: 1, email: 'a@b.de', role: 'USER', displayName: 'Lobo', steamId: '76561198000000001', steamIdVerified: false,
settings: {useDarkTheme: true, language: 'de', publicProfile: false}, ...overrides, settings: {useDarkTheme: true, language: 'de', publicProfile: false}, ...overrides,
}); });
+1 -1
View File
@@ -25,7 +25,7 @@ vi.mock('react-i18next', async () => {
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn(), info: vi.fn()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn(), info: vi.fn()}}));
const profile = { const profile = {
id: 1, email: 'a@b.de', role: 'USER', displayName: 'Lobo', steamId: '76561198005679788', steamIdVerified: true, id: 1, email: 'a@b.de', role: 'USER', displayName: 'Lobo', steamId: '76561198000000001', steamIdVerified: true,
settings: {useDarkTheme: true, language: 'de', publicProfile: false}, settings: {useDarkTheme: true, language: 'de', publicProfile: false},
}; };
+1 -1
View File
@@ -14,7 +14,7 @@ vi.mock('react-i18next', async () => {
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const profile = (overrides = {}) => ({ const profile = (overrides = {}) => ({
id: 1, email: 'a@b.de', role: 'USER', displayName: 'Lobo', steamId: '76561198005679788', id: 1, email: 'a@b.de', role: 'USER', displayName: 'Lobo', steamId: '76561198000000001',
settings: {useDarkTheme: true, language: 'de', publicProfile: true}, ...overrides, settings: {useDarkTheme: true, language: 'de', publicProfile: true}, ...overrides,
}); });
+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, * 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 * read, details}], unread}`, the newest 50 notes. `kind` is `DIGEST`, `VISIBILITY`, `PSN_EXPIRING` or `PSN_EXPIRED`
* kinds have no `displayName`); `unread` counts all unread notes. * (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()); 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. */ /** All notes count as read. 204. */
export const markInboxRead = () => request(`${INBOX()}/read`, {method: 'PUT'}); export const markInboxRead = () => request(`${INBOX()}/read`, {method: 'PUT'});
@@ -21,3 +28,19 @@ const PARTS = [['NEW_GAMES', 'newGames'], ['ACHIEVEMENTS', 'achievements'], ['ST
*/ */
export const digestParts = (note) => export const digestParts = (note) =>
PARTS.filter(([, field]) => note[field] > 0).map(([key, field]) => ({key, count: note[field]})); 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 {describe, expect, it} from 'vitest';
import {digestParts} from './inbox.js'; import {digestParts, groupDetails} from './inbox.js';
describe('digestParts', () => { describe('digestParts', () => {
it('keepsOnlyTheNonZeroCountsInAFixedOrder', () => { it('keepsOnlyTheNonZeroCountsInAFixedOrder', () => {
@@ -13,3 +13,22 @@ describe('digestParts', () => {
expect(digestParts({newGames: 0, achievements: 0, statusChanges: 0, wishlistAdds: 0})).toEqual([]); 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([]);
});
});
+2 -2
View File
@@ -5,10 +5,10 @@ const settings = () => ({useDarkTheme: true, language: 'de'});
describe('Steam ID', () => { describe('Steam ID', () => {
it('is no form field any more: it comes only from "Sign in through Steam"', () => { it('is no form field any more: it comes only from "Sign in through Steam"', () => {
expect(settingsSchema.parse({...settings(), steamId: '76561198005679788'})).not.toHaveProperty('steamId'); expect(settingsSchema.parse({...settings(), steamId: '76561198000000001'})).not.toHaveProperty('steamId');
expect(registrationSchema.parse({ expect(registrationSchema.parse({
email: 'a@b.de', email: 'a@b.de',
steamId: '76561198005679788', steamId: '76561198000000001',
})).not.toHaveProperty('steamId'); })).not.toHaveProperty('steamId');
}); });
}); });