Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec0b2844c3 | ||
|
|
c599054059 | ||
|
|
ad6f5575d2 | ||
|
|
600cf07b5a |
No files matched your search
@@ -5,9 +5,9 @@ you finished. The UI is in German and English, with light and dark mode.
|
||||
|
||||

|
||||
|
||||
| Game detail | Public profile | Mobile |
|
||||
|---|---|---|
|
||||
|  |  |  |
|
||||
| Dashboard | Game detail | Public profile | Mobile |
|
||||
|---|---|---|---|
|
||||
|  |  |  |  |
|
||||
|
||||
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
|
||||
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"
|
||||
@@ -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`
|
||||
(`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`.
|
||||
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.
|
||||
|
||||
The dev user is `PLATFORM_ADMIN`, so user, auditor and admin pages can all be tried ("View as" in the menu
|
||||
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 433 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 330 KiB After Width: | Height: | Size: 458 KiB |
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -38,6 +38,5 @@ it('checksThePointsAgainAfterTheAchievementRefresh', async () => {
|
||||
|
||||
// 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}));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(pointsCalls()).toBe(2);
|
||||
await waitFor(() => expect(pointsCalls()).toBe(2));
|
||||
});
|
||||
@@ -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')}>
|
||||
{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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 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,
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock('react-i18next', async () => {
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn(), info: vi.fn()}}));
|
||||
|
||||
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},
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock('react-i18next', async () => {
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,10 @@ const settings = () => ({useDarkTheme: true, language: 'de'});
|
||||
|
||||
describe('Steam ID', () => {
|
||||
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({
|
||||
email: 'a@b.de',
|
||||
steamId: '76561198005679788',
|
||||
steamId: '76561198000000001',
|
||||
})).not.toHaveProperty('steamId');
|
||||
});
|
||||
});
|
||||
|
||||