diff --git a/README.md b/README.md index 2b143e5..85cd6d9 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,17 @@ flowchart LR Dialog -->|"community row, login"| Owners["Core /api/stats/owners"] Landing["Landing user tiles, login"] --> Users["Auth /api/stats/users"] ``` +- `/questions` (`QuestionsPage`, nav item with the open count; the badge is hidden on the page, whose title shows the + live count) lists one card per game with an open question (Start it? / Finished? / Dropped? / Rate it?). Core decides + which question is open; `src/utils/questions.js` only loads the list and sends the answer. A yes to Finished? gets + the Rate it? card back at once. `409` / `404` reload the list. `RatingStars` is shared with `BacklogCard`. + +```mermaid +flowchart LR + Header["AppHeader badge"] --> Count["Core /api/library/questions/count"] + Page["QuestionsPage"] --> List["Core /api/library/questions"] + Page -->|"Yes / No / stars"| Answer["Core POST /api/library/questions/{appid}"] +``` - Owner and user lists (`src/components/people/`, `src/utils/people.js`) need a login and show listed users by name (a link to `/u/`), everyone else as "+N private", the viewer as "You". The setting "show me in owner lists" is saved on its own (`PUT /api/users/settings/owner-lists`), never through `saveSettings`. diff --git a/public/i18n/de.json b/public/i18n/de.json index aa5ff42..0299ebc 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -392,6 +392,9 @@ "INBOX": "Postfach", "INBOX_UNREAD_one": "Postfach, {{count}} neue Nachricht", "INBOX_UNREAD_other": "Postfach, {{count}} neue Nachrichten", + "QUESTIONS": "Fragen", + "QUESTIONS_OPEN_one": "Fragen, {{count}} offen", + "QUESTIONS_OPEN_other": "Fragen, {{count}} offen", "WHATS_NEW": "Neuigkeiten", "ADMIN_NEWS": "Neuigkeiten verwalten", "ADMIN_BROADCASTS": "Rundmail", @@ -1291,5 +1294,31 @@ "ACHIEVEMENTS": "Neue Erfolge oder Trophäen", "STATUS": "Statuswechsel" } + }, + "QUESTIONS": { + "TITLE": "Fragen", + "TITLE_COUNT": "Fragen ({{count}})", + "EMPTY": "Gerade gibt es nichts zu entscheiden.", + "GONE": "Diese Frage ist schon beantwortet. Die Liste wird neu geladen.", + "YES": "Ja", + "NO": "Nein", + "SKIP": "Überspringen", + "START": { + "ASK": "Angefangen?", + "REASON": "Du hast {{playtime}} gespielt, das Spiel liegt aber noch im Backlog." + }, + "FINISHED": { + "ASK": "Durchgespielt?", + "REASON": "Du hast {{playtime}} gespielt.", + "REASON_TTB": "Du hast {{playtime}} gespielt. Die meisten brauchen {{ttb}}." + }, + "DROPPED": { + "ASK": "Abgebrochen?", + "REASON": "Keine Spielzeit seit {{date}}." + }, + "RATE": { + "ASK": "Bewerten?", + "REASON": "Du hast es durchgespielt. Wie war es?" + } } } diff --git a/public/i18n/en.json b/public/i18n/en.json index 037de4f..c68bbd1 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -392,6 +392,9 @@ "INBOX": "Inbox", "INBOX_UNREAD_one": "Inbox, {{count}} new note", "INBOX_UNREAD_other": "Inbox, {{count}} new notes", + "QUESTIONS": "Questions", + "QUESTIONS_OPEN_one": "Questions, {{count}} open", + "QUESTIONS_OPEN_other": "Questions, {{count}} open", "WHATS_NEW": "What's new", "ADMIN_NEWS": "News", "ADMIN_BROADCASTS": "Mail", @@ -1291,5 +1294,31 @@ "ACHIEVEMENTS": "New achievements or trophies", "STATUS": "Status changes" } + }, + "QUESTIONS": { + "TITLE": "Questions", + "TITLE_COUNT": "Questions ({{count}})", + "EMPTY": "Nothing to decide right now.", + "GONE": "That question was already answered. The list is loaded again.", + "YES": "Yes", + "NO": "No", + "SKIP": "Skip", + "START": { + "ASK": "Start it?", + "REASON": "You played {{playtime}}, but it is still in your backlog." + }, + "FINISHED": { + "ASK": "Finished?", + "REASON": "You played {{playtime}}.", + "REASON_TTB": "You played {{playtime}}. Most players need {{ttb}}." + }, + "DROPPED": { + "ASK": "Dropped?", + "REASON": "No playtime since {{date}}." + }, + "RATE": { + "ASK": "Rate it?", + "REASON": "You finished it. How was it?" + } } } diff --git a/src/App.jsx b/src/App.jsx index a2a3761..3e924b0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -14,6 +14,7 @@ import AdminHintsPage from './pages/AdminHintsPage.jsx'; import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx'; import AdminNewsPage from './pages/AdminNewsPage.jsx'; import InboxPage from './pages/InboxPage.jsx'; +import QuestionsPage from './pages/QuestionsPage.jsx'; import WhatsNewPage from './pages/WhatsNewPage.jsx'; import WelcomePage from './pages/WelcomePage.jsx'; import WelcomeGate from './components/WelcomeGate.jsx'; @@ -93,6 +94,7 @@ function App() { }/> }/> }/> + }/> }/> }/> }/> diff --git a/src/App.test.jsx b/src/App.test.jsx index 057a015..ca45bfc 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -35,6 +35,7 @@ describe('App', () => { 'GET /api/library/steam-profile': json(200, null), 'POST /api/auth/refresh': json(401, {}), 'GET /api/inbox': json(200, {results: [], unread: 0}), + 'GET /api/library/questions/count': json(200, {count: 0}), }); const {router} = renderWithProviders(, {entries: ['/library']}); await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy()); diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index 857fb9c..63814d9 100644 --- a/src/components/AppHeader.jsx +++ b/src/components/AppHeader.jsx @@ -1,7 +1,7 @@ import {useEffect, useState} from 'react'; import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; -import {Bell, ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun, Users} from 'lucide-react'; +import {Bell, ChartColumn, CircleQuestionMark, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun, Users} from 'lucide-react'; import {toast} from 'sonner'; import {Badge} from '@/components/ui/badge'; @@ -26,6 +26,7 @@ import useSteamProfile from '../hooks/useSteamProfile.js'; import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js'; import {canCorrect} from '../utils/corrections.js'; import {fetchInbox} from '../utils/inbox.js'; +import {fetchQuestionCount} from '../utils/questions.js'; import {expireSession, markSession} from '../utils/auth.js'; const NAV_ITEMS = [ @@ -33,10 +34,18 @@ const NAV_ITEMS = [ // The wishlist and the backlog order are tabs of the library. {to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist', '/backlog']}, {to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices}, + {to: '/questions', labelKey: 'NAV.QUESTIONS', Icon: CircleQuestionMark}, {to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn}, {to: '/', labelKey: 'NAV.COMMUNITY', Icon: Users, iconOnly: true}, ]; +const CountBadge = ({count}) => ( + +); + const navLinkClass = (isActive) => cn( 'flex items-center gap-2 rounded-lg px-1 py-2 text-sm font-medium transition-colors sm:px-3', isActive @@ -86,6 +95,22 @@ export default function AppHeader({isAuthenticated, onLogout}) { }, [isAuthenticated, pathname]); const shownUnread = pathname === '/inbox' ? 0 : unread; + // Loaded again on every page change. The questions page shows the live count in its title, so it has no badge. + const [openQuestions, setOpenQuestions] = useState(0); + useEffect(() => { + if (!isAuthenticated) return undefined; + let current = true; + fetchQuestionCount().then(({ok, data}) => { + if (current && ok) setOpenQuestions(data.count); + }); + return () => { + current = false; + }; + }, [isAuthenticated, pathname]); + const shownQuestions = pathname === '/questions' ? 0 : openQuestions; + // Below sm the Questions icon shows only with open questions or on its own page; the points chip then gives up its number. + const questionsIconHidden = openQuestions === 0 && pathname !== '/questions'; + const switchRole = async (role) => { try { const response = await viewAs(role === currentUser.role ? null : role); @@ -129,13 +154,19 @@ export default function AppHeader({isAuthenticated, onLogout}) { {isAuthenticated && ( )} @@ -146,19 +177,17 @@ export default function AppHeader({isAuthenticated, onLogout}) { )} {isAuthenticated && wallet && ( )} {isAuthenticated && ( )} diff --git a/src/components/AppHeader.points.test.jsx b/src/components/AppHeader.points.test.jsx index b83e761..c225108 100644 --- a/src/components/AppHeader.points.test.jsx +++ b/src/components/AppHeader.points.test.jsx @@ -18,13 +18,44 @@ function Load() { return ; } +async function loadChip(count) { + localStorage.setItem('tbp_session', '1'); + fetchRoutes({ + 'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}), + 'GET /api/library/steam-profile': json(200, {}), + 'GET /api/inbox': json(200, {results: [], unread: 0}), + 'GET /api/library/questions/count': json(200, {count}), + 'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}), + }); + renderWithProviders(<> {}}/>); + fireEvent.click(screen.getByText('load')); + return screen.findByTitle('POINTS.TITLE: 135'); +} + describe('AppHeader points chip', () => { + it('hides the number below sm while the questions icon shows, and keeps the balance as label', async () => { + const chip = await loadChip(2); + await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:2'}); + + expect(chip.getAttribute('aria-label')).toBe('POINTS.TITLE: 135'); + expect(screen.getByText('135').className).toContain('max-sm:hidden'); + }); + + it('shows the number at every width while the questions icon is hidden', async () => { + const chip = await loadChip(0); + await screen.findByTitle('NAV.QUESTIONS'); + + expect(chip.getAttribute('aria-label')).toBe('POINTS.TITLE: 135'); + expect(screen.getByText('135').className).not.toContain('max-sm:hidden'); + }); + it('shows the balance as a link to the points page once the wallet is loaded', async () => { localStorage.setItem('tbp_session', '1'); fetchRoutes({ 'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}), 'GET /api/library/steam-profile': json(200, {}), 'GET /api/inbox': json(200, {results: [], unread: 0}), + 'GET /api/library/questions/count': json(200, {count: 0}), 'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}), }); renderWithProviders(<> {}}/>); diff --git a/src/components/AppHeader.test.jsx b/src/components/AppHeader.test.jsx index 831b9f9..bfc65fd 100644 --- a/src/components/AppHeader.test.jsx +++ b/src/components/AppHeader.test.jsx @@ -19,7 +19,8 @@ afterEach(() => { const loggedInAs = (me, routes = {}, options = {}) => { localStorage.setItem('tbp_session', '1'); fetchRoutes({'GET /api/users/me': json(200, me), 'GET /api/library/steam-profile': json(200, {}), - 'GET /api/inbox': json(200, {results: [], unread: 0}), ...routes}); + 'GET /api/inbox': json(200, {results: [], unread: 0}), + 'GET /api/library/questions/count': json(200, {count: 0}), ...routes}); renderWithProviders( {}}/>, options); }; @@ -86,6 +87,44 @@ describe('AppHeader', () => { expect(link.textContent).toBe(''); }); + it('shows how many questions are open', async () => { + loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 12})}); + + const link = await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:12'}); + + expect(link.getAttribute('href')).toBe('/questions'); + expect(link.textContent).toBe('NAV.QUESTIONS9+'); + }); + + it('hides the questions icon below sm while no question is open', async () => { + loggedInAs({role: 'USER', activeRole: 'USER'}); + + expect((await screen.findByTitle('NAV.QUESTIONS')).className).toContain('max-sm:hidden'); + }); + + it('keeps the questions icon at every width while questions are open', async () => { + loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 2})}); + + expect((await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:2'})).className).not.toContain('max-sm:hidden'); + }); + + it('keeps the questions icon below sm on the questions page, without a badge', async () => { + loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 0})}, + {entries: ['/questions']}); + + expect((await screen.findByTitle('NAV.QUESTIONS')).className).not.toContain('max-sm:hidden'); + }); + + it('shows no question count on the questions page, whose title has it', async () => { + loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 3})}, + {entries: ['/questions']}); + + await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/library/questions/count'), expect.anything())); + + expect(screen.getByTitle('NAV.QUESTIONS').textContent).toBe('NAV.QUESTIONS'); + expect(screen.queryByRole('link', {name: 'NAV.QUESTIONS_OPEN:3'})).toBeNull(); + }); + it('offers the admin page and "view as" to an admin', async () => { loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'}, {'GET /api/admin/users/pending-count': json(200, {count: 0})}); diff --git a/src/components/BacklogCard.jsx b/src/components/BacklogCard.jsx index b345e9f..74bdb4f 100644 --- a/src/components/BacklogCard.jsx +++ b/src/components/BacklogCard.jsx @@ -1,14 +1,14 @@ import {useEffect, useState} from 'react'; import {useTranslation} from 'react-i18next'; import {toast} from 'sonner'; -import {ListTodo, Loader2, Star} from 'lucide-react'; +import {ListTodo, Loader2} from 'lucide-react'; import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; import {Label} from '@/components/ui/label'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; import {Textarea} from '@/components/ui/textarea'; -import {cn} from '@/lib/utils'; +import RatingStars from './RatingStars.jsx'; import {STATUS_META} from './GameStatusBadge.jsx'; import {changedFields, NO_PRIORITY, saveBacklogEntry} from '../utils/backlog.js'; import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js'; @@ -104,21 +104,7 @@ export default function BacklogCard({game, onSaved}) {
-
- {[1, 2, 3, 4, 5].map((n) => ( - - ))} -
+
diff --git a/src/components/QuestionCard.jsx b/src/components/QuestionCard.jsx new file mode 100644 index 0000000..82d5fdc --- /dev/null +++ b/src/components/QuestionCard.jsx @@ -0,0 +1,64 @@ +import {Link} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; + +import {Button} from '@/components/ui/button'; +import {Card} from '@/components/ui/card'; +import FitText from './FitText.jsx'; +import GameCover from './GameCover.jsx'; +import RatingStars from './RatingStars.jsx'; +import {formatDate} from '../utils/format.js'; +import {formatPlaytime} from '../utils/games.js'; + +/** + * One open question of one game. The page decides what an answer does. + * + * @param item a result of `GET /api/library/questions` + * @param busy true while an answer is on its way + * @param onAnswer `({yes, rating?})`; a yes to the rating question comes with the stars + */ +export default function QuestionCard({item, busy, onAnswer}) { + const {t, i18n} = useTranslation(); + const {appid, name, imageUrl, question, playtimeMinutes, normallyMinutes, lastActivity} = item; + const reasonKey = question === 'FINISHED' && normallyMinutes > 0 ? 'FINISHED.REASON_TTB' : `${question}.REASON`; + const reason = t(`QUESTIONS.${reasonKey}`, { + playtime: formatPlaytime(playtimeMinutes), + ttb: formatPlaytime(normallyMinutes), + date: lastActivity ? formatDate(lastActivity, i18n.language) : '', + }); + const askId = `question-${appid}`; + + return ( + + + + +
+ + + +

{t(`QUESTIONS.${question}.ASK`)}

+

{reason}

+ {question === 'RATE' ? ( +
+ onAnswer({yes: true, rating})}/> + +
+ ) : ( +
+ + +
+ )} +
+
+ ); +} diff --git a/src/components/QuestionCard.test.jsx b/src/components/QuestionCard.test.jsx new file mode 100644 index 0000000..e3e2ce9 --- /dev/null +++ b/src/components/QuestionCard.test.jsx @@ -0,0 +1,51 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, fireEvent, render, screen} from '@testing-library/react'; +import {MemoryRouter} from 'react-router-dom'; +import QuestionCard from './QuestionCard.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); + +afterEach(cleanup); + +const item = (overrides) => ({appid: 10, name: 'Hades', imageUrl: null, question: 'RATE', status: 'FINISHED', + playtimeMinutes: 1500, normallyMinutes: null, lastActivity: null, ...overrides}); + +const renderCard = (props, onAnswer = vi.fn()) => { + render(); + return onAnswer; +}; + +describe('QuestionCard', () => { + it('sendsTheStarsAsARating', () => { + const onAnswer = renderCard(); + + fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:4'})); + + expect(onAnswer).toHaveBeenCalledWith({yes: true, rating: 4}); + }); + + it('sendsNoForSkip', () => { + const onAnswer = renderCard(); + + fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.SKIP'})); + + expect(onAnswer).toHaveBeenCalledWith({yes: false}); + }); + + it('answersYesAndNoWithoutARating', () => { + const onAnswer = renderCard({question: 'START', status: 'BACKLOG'}); + + fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.YES'})); + fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.NO'})); + + expect(onAnswer.mock.calls).toEqual([[{yes: true}], [{yes: false}]]); + expect(screen.queryByRole('button', {name: 'QUESTIONS.SKIP'})).toBeNull(); + }); + + it('linksTheNameToTheGamePage', () => { + renderCard(); + + expect(screen.getByRole('link', {name: 'Hades'}).getAttribute('href')).toBe('/library/10'); + }); +}); diff --git a/src/components/RatingStars.jsx b/src/components/RatingStars.jsx new file mode 100644 index 0000000..bb6c700 --- /dev/null +++ b/src/components/RatingStars.jsx @@ -0,0 +1,32 @@ +import {useTranslation} from 'react-i18next'; +import {Star} from 'lucide-react'; + +import {cn} from '@/lib/utils'; + +/** + * Five star buttons. Clicking the chosen star again sends 0, so the parent decides what that means. + * + * @param value 0 for no rating + * @param labelledBy id of the element that names the group + */ +export default function RatingStars({value, onChange, labelledBy, disabled}) { + const {t} = useTranslation(); + return ( +
+ {[1, 2, 3, 4, 5].map((n) => ( + + ))} +
+ ); +} diff --git a/src/pages/QuestionsPage.jsx b/src/pages/QuestionsPage.jsx new file mode 100644 index 0000000..e8f0858 --- /dev/null +++ b/src/pages/QuestionsPage.jsx @@ -0,0 +1,73 @@ +import {useCallback, useEffect, useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {Loader2} from 'lucide-react'; +import {toast} from 'sonner'; + +import PageContainer from '../components/PageContainer.jsx'; +import QuestionCard from '../components/QuestionCard.jsx'; +import {answerQuestion, fetchQuestions} from '../utils/questions.js'; + +/** + * The open questions about the user's games: one card per game, answered one click at a time. Core decides which + * question is open; a yes changes the game like the game page does. + */ +export default function QuestionsPage() { + const {t} = useTranslation(); + const [items, setItems] = useState([]); + const [status, setStatus] = useState('loading'); + const [busyAppid, setBusyAppid] = useState(null); + + const load = useCallback(async () => { + const {ok, status: code, data} = await fetchQuestions(); + if (code === 401) return; + if (!ok) { + setStatus('error'); + return; + } + setItems(data.results); + setStatus('ready'); + }, []); + + useEffect(() => { + load(); + }, [load]); + + const answer = async (item, choice) => { + setBusyAppid(item.appid); + try { + const {ok, status: code, data} = await answerQuestion(item.appid, {question: item.question, ...choice}); + if (code === 401) return; + if (ok) { + // The next question of the same game (Rate it? after Finished?) takes the place of the card. + setItems((current) => current.flatMap((entry) => (entry.appid === item.appid ? [data.next].filter(Boolean) : [entry]))); + toast.success(t('BACKLOG.SAVED')); + } else if (code === 404 || code === 409) { + toast(t('QUESTIONS.GONE')); + await load(); + } else if (code === 0) { + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } else { + toast.error(t('BACKLOG.SAVE_FAILED')); + } + } finally { + setBusyAppid(null); + } + }; + + return ( + +

+ {status === 'ready' && items.length > 0 ? t('QUESTIONS.TITLE_COUNT', {count: items.length}) : t('QUESTIONS.TITLE')} +

+ {status === 'loading' && } + {status === 'error' &&

{t('INFO.ERROR.NETWORK_FAILURE')}

} + {status === 'ready' && items.length === 0 && ( +

{t('QUESTIONS.EMPTY')}

+ )} + {status === 'ready' && items.map((item) => ( + answer(item, choice)}/> + ))} +
+ ); +} diff --git a/src/pages/QuestionsPage.test.jsx b/src/pages/QuestionsPage.test.jsx new file mode 100644 index 0000000..e1c00c8 --- /dev/null +++ b/src/pages/QuestionsPage.test.jsx @@ -0,0 +1,133 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {MemoryRouter} from 'react-router-dom'; +import {toast} from 'sonner'; +import QuestionsPage from './QuestionsPage.jsx'; +import {PreferencesProvider} from '../context/PreferencesContext.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})})); + +const card = (appid, question, name = `Game ${appid}`) => ({appid, name, imageUrl: null, question, status: 'PLAYING', + playtimeMinutes: 1500, normallyMinutes: null, lastActivity: null}); + +const renderPage = (routes) => { + const mock = fetchRoutes(routes); + render(); + return mock; +}; + +const bodyOf = (mock) => JSON.parse(mock.mock.calls.find(([, init]) => init?.method === 'POST')[1].body); + +beforeEach(() => { + localStorage.setItem('tbp_session', '1'); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + vi.clearAllMocks(); +}); + +describe('QuestionsPage', () => { + it('showsOneCardPerGameWithTheCountInTheTitle', async () => { + renderPage({'GET /api/library/questions': json(200, {results: [card(1, 'START'), card(2, 'FINISHED')]})}); + + expect(await screen.findByText('Game 1')).toBeTruthy(); + expect(screen.getByText('Game 2')).toBeTruthy(); + expect(screen.getByText('QUESTIONS.START.ASK')).toBeTruthy(); + expect(screen.getByText('QUESTIONS.TITLE_COUNT:2')).toBeTruthy(); + }); + + it('removesTheCardAfterAYes', async () => { + const mock = renderPage({ + 'GET /api/library/questions': json(200, {results: [card(1, 'START'), card(2, 'START')]}), + 'POST /api/library/questions/1': json(200, {next: null}), + }); + await screen.findByText('Game 1'); + + fireEvent.click(screen.getAllByRole('button', {name: 'QUESTIONS.YES'})[0]); + + await waitFor(() => expect(screen.queryByText('Game 1')).toBeNull()); + expect(screen.getByText('Game 2')).toBeTruthy(); + expect(bodyOf(mock)).toEqual({question: 'START', yes: true}); + expect(toast.success).toHaveBeenCalledWith('BACKLOG.SAVED'); + }); + + it('removesTheCardAfterANo', async () => { + const mock = renderPage({ + 'GET /api/library/questions': json(200, {results: [card(1, 'DROPPED')]}), + 'POST /api/library/questions/1': json(200, {next: null}), + }); + await screen.findByText('Game 1'); + + fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.NO'})); + + expect(await screen.findByText('QUESTIONS.EMPTY')).toBeTruthy(); + expect(bodyOf(mock)).toEqual({question: 'DROPPED', yes: false}); + }); + + it('replacesTheCardWithTheRateQuestionAfterFinished', async () => { + renderPage({ + 'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED')]}), + 'POST /api/library/questions/1': json(200, {next: card(1, 'RATE')}), + }); + await screen.findByText('QUESTIONS.FINISHED.ASK'); + + fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.YES'})); + + expect(await screen.findByText('QUESTIONS.RATE.ASK')).toBeTruthy(); + expect(screen.queryByText('QUESTIONS.FINISHED.ASK')).toBeNull(); + }); + + it('sendsTheRatingOfTheStars', async () => { + const mock = renderPage({ + 'GET /api/library/questions': json(200, {results: [card(1, 'RATE')]}), + 'POST /api/library/questions/1': json(200, {next: null}), + }); + await screen.findByText('QUESTIONS.RATE.ASK'); + + fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:5'})); + + await screen.findByText('QUESTIONS.EMPTY'); + expect(bodyOf(mock)).toEqual({question: 'RATE', yes: true, rating: 5}); + }); + + it('loadsTheListAgainWhenTheQuestionIsGone', async () => { + let results = [card(1, 'START')]; + renderPage({ + 'GET /api/library/questions': () => json(200, {results}), + 'POST /api/library/questions/1': () => { + results = []; + return json(409, {code: 'QUESTION_GONE'}); + }, + }); + await screen.findByText('Game 1'); + + fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.YES'})); + + expect(await screen.findByText('QUESTIONS.EMPTY')).toBeTruthy(); + expect(toast).toHaveBeenCalledWith('QUESTIONS.GONE'); + }); + + it('keepsTheCardAndSaysSoWhenSavingFails', async () => { + renderPage({ + 'GET /api/library/questions': json(200, {results: [card(1, 'START')]}), + 'POST /api/library/questions/1': json(500, {}), + }); + await screen.findByText('Game 1'); + + fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.YES'})); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED')); + expect(screen.getByText('Game 1')).toBeTruthy(); + }); + + it('showsTheEmptyState', async () => { + renderPage({'GET /api/library/questions': json(200, {results: []})}); + + expect(await screen.findByText('QUESTIONS.EMPTY')).toBeTruthy(); + }); +}); diff --git a/src/utils/questions.js b/src/utils/questions.js new file mode 100644 index 0000000..73ba376 --- /dev/null +++ b/src/utils/questions.js @@ -0,0 +1,24 @@ +import {request} from './api.js'; + +const QUESTIONS = () => `${import.meta.env.VITE_CORE_API_URL}/api/library/questions`; + +/** + * GET /api/library/questions: `{results: [{appid, name, imageUrl, question, status, playtimeMinutes, normallyMinutes, + * lastActivity}]}`, one open question per game, most important first. `question` is `START`, `FINISHED`, `DROPPED` or + * `RATE`; `normallyMinutes` is IGDB's time to beat or null; `lastActivity` (an ISO time) only comes with `DROPPED`. + */ +export const fetchQuestions = () => request(QUESTIONS()); + +/** GET /api/library/questions/count: `{count}`, the number of cards the list has. */ +export const fetchQuestionCount = () => request(`${QUESTIONS()}/count`); + +/** + * POST /api/library/questions/{appid}: `{question, yes, rating?}`. A yes to `RATE` needs `rating` (1 to 5). 200 is + * `{next}`: the next question of the same game (e.g. `RATE` after `FINISHED`) or null. 404 `GAME_NOT_FOUND`, 409 + * `QUESTION_GONE` (the question is not open any more). + */ +export const answerQuestion = (appid, {question, yes, rating}) => request(`${QUESTIONS()}/${appid}`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({question, yes, rating}), +});