From ece18d125b762bb332ef16dbd39272d473aa028e Mon Sep 17 00:00:00 2001 From: Samu Date: Thu, 3 Sep 2026 22:45:20 +0200 Subject: [PATCH] feat: play button, last played, loan lend/return on item page --- server/src/routes/collectionRoutes.ts | 9 ++- server/test/loans.test.ts | 16 +++++ web/src/pages/ItemPage.tsx | 96 +++++++++++++++++++++++---- web/test/item.test.tsx | 78 ++++++++++++++++++++-- 4 files changed, 179 insertions(+), 20 deletions(-) diff --git a/server/src/routes/collectionRoutes.ts b/server/src/routes/collectionRoutes.ts index 71a7b08..b0fa2c3 100644 --- a/server/src/routes/collectionRoutes.ts +++ b/server/src/routes/collectionRoutes.ts @@ -179,7 +179,14 @@ export async function registerCollectionRoutes(app: FastifyInstance): Promise { diff --git a/server/test/loans.test.ts b/server/test/loans.test.ts index 9d7df05..900a2ba 100644 --- a/server/test/loans.test.ts +++ b/server/test/loans.test.ts @@ -56,6 +56,22 @@ describe('loans', () => { await app.close() }) + it('detail route reports the active loan, null before lending', async () => { + const { app, cookie, itemId } = await appWithItem() + const before = await app.inject({ method: 'GET', url: `/api/collection/${itemId}`, ...auth(cookie) }) + expect(before.json().loan).toBeNull() + + await app.inject({ + method: 'POST', + url: `/api/collection/${itemId}/loan`, + ...auth(cookie), + payload: { borrower: 'Bob' }, + }) + const after = await app.inject({ method: 'GET', url: `/api/collection/${itemId}`, ...auth(cookie) }) + expect(after.json().loan).toMatchObject({ borrower: 'Bob' }) + await app.close() + }) + it('validates borrower and ownership', async () => { const { app, cookie, itemId } = await appWithItem() const empty = await app.inject({ diff --git a/web/src/pages/ItemPage.tsx b/web/src/pages/ItemPage.tsx index 2da8f29..81ade21 100644 --- a/web/src/pages/ItemPage.tsx +++ b/web/src/pages/ItemPage.tsx @@ -1,13 +1,29 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { api } from '../api.js' -import type { DigitalAlbum, Item } from '../types.js' +import { usePlayer } from '../player/PlayerContext.js' +import type { DigitalAlbum, Item, ItemDetail } from '../types.js' import Cover from '../components/Cover.js' +function timeAgo(iso: string): string { + const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000) + if (secs < 60) return 'just now' + const mins = Math.floor(secs / 60) + if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago` + const days = Math.floor(hours / 24) + if (days < 30) return `${days} day${days === 1 ? '' : 's'} ago` + const months = Math.floor(days / 30) + if (months < 12) return `${months} month${months === 1 ? '' : 's'} ago` + return `${Math.floor(months / 12)} year${months >= 24 ? 's' : ''} ago` +} + export default function ItemPage() { const { id } = useParams() const navigate = useNavigate() - const [item, setItem] = useState(null) + const { load } = usePlayer() + const [item, setItem] = useState(null) const [error, setError] = useState(false) const [matching, setMatching] = useState(false) const [albumQuery, setAlbumQuery] = useState('') @@ -16,13 +32,19 @@ export default function ItemPage() { const [confirmRemove, setConfirmRemove] = useState(false) const [mutationError, setMutationError] = useState(null) + const refetch = useCallback(() => { + void api.getItem(Number(id)).then(setItem).catch(() => setError(true)) + }, [id]) + useEffect(() => { setMutationError(null) - void api - .getItem(Number(id)) - .then(setItem) - .catch(() => setError(true)) - }, [id]) + refetch() + }, [refetch]) + + /** Rip/match mutations return a plain Item — keep the detail-only fields. */ + function applyUpdated(updated: Item) { + setItem((prev) => (prev ? { ...updated, matchedAlbum: prev.matchedAlbum, loan: prev.loan } : null)) + } function searchAlbums() { void api @@ -39,7 +61,7 @@ export default function ItemPage() { void api .setMatch(item.id, albumId) .then((updated) => { - setItem(updated) + applyUpdated(updated) setMutationError(null) }) .catch(() => setMutationError("That didn't work — check your connection and try again.")) @@ -91,6 +113,19 @@ export default function ItemPage() {

)} + {item.ripStatus === 'ripped' && item.matchedAlbum && ( + + )} + {item.matchedAlbum?.lastPlayedAt && ( +

Last played {timeAgo(item.matchedAlbum.lastPlayedAt)}

+ )} + {mutationError &&

{mutationError}

}
@@ -101,7 +136,7 @@ export default function ItemPage() { void api .setRip(item.id, false) .then((updated) => { - setItem(updated) + applyUpdated(updated) setMutationError(null) }) .catch(() => setMutationError("That didn't work — check your connection and try again.")) @@ -117,7 +152,7 @@ export default function ItemPage() { void api .setRip(item.id, true) .then((updated) => { - setItem(updated) + applyUpdated(updated) setMutationError(null) }) .catch(() => setMutationError("That didn't work — check your connection and try again.")) @@ -134,7 +169,7 @@ export default function ItemPage() { void api .setRip(item.id, null) .then((updated) => { - setItem(updated) + applyUpdated(updated) setMutationError(null) }) .catch(() => setMutationError("That didn't work — check your connection and try again.")) @@ -209,6 +244,43 @@ export default function ItemPage() { )} +
+ {item.loan ? ( +
+

+ Out to {item.loan.borrower} since {new Date(item.loan.lentAt).toLocaleDateString()} +

+ +
+ ) : ( +
{ + e.preventDefault() + const borrower = (e.currentTarget.elements.namedItem('borrower') as HTMLInputElement).value + void api.lendItem(item.id, borrower).then(refetch) + }} + className="flex gap-2" + > + + +
+ )} +
+ {item.tracklist.length > 0 && (
Tracklist diff --git a/web/test/item.test.tsx b/web/test/item.test.tsx index 1557a8b..92db6a1 100644 --- a/web/test/item.test.tsx +++ b/web/test/item.test.tsx @@ -3,19 +3,31 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter, Route, Routes } from 'react-router-dom' import ItemPage from '../src/pages/ItemPage.js' -import type { Item } from '../src/types.js' +import { PlayerProvider } from '../src/player/PlayerContext.js' +import type { ItemDetail, MatchedAlbum } from '../src/types.js' vi.mock('../src/api.js', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - api: { ...actual.api, getItem: vi.fn(), setRip: vi.fn(), setMatch: vi.fn(), deleteItem: vi.fn(), searchAlbums: vi.fn() }, + api: { + ...actual.api, + getItem: vi.fn(), + setRip: vi.fn(), + setMatch: vi.fn(), + deleteItem: vi.fn(), + searchAlbums: vi.fn(), + getAlbumTracks: vi.fn(), + markPlayed: vi.fn(), + lendItem: vi.fn(), + returnLoan: vi.fn(), + }, } }) import { api } from '../src/api.js' -const item: Item = { +const item: ItemDetail = { id: 1, discogsReleaseId: 1001, title: 'Motion', @@ -32,24 +44,37 @@ const item: Item = { dateAdded: '2026-08-29', ripOverride: null, ripStatus: 'not_ripped', + matchedAlbum: null, + loan: null, } +const matched: MatchedAlbum = { id: 77, subsonicId: 'alb-1', lastPlayedAt: '2026-09-01T10:00:00Z' } +const rippedItem: ItemDetail = { ...item, ripStatus: 'ripped', matchedAlbum: matched } + beforeEach(() => { + vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue() + vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined) vi.mocked(api.getItem).mockReset() vi.mocked(api.getItem).mockResolvedValue(item as never) vi.mocked(api.setRip).mockReset() vi.mocked(api.setMatch).mockReset() vi.mocked(api.deleteItem).mockReset() vi.mocked(api.searchAlbums).mockReset() + vi.mocked(api.getAlbumTracks).mockReset() + vi.mocked(api.markPlayed).mockReset() + vi.mocked(api.lendItem).mockReset() + vi.mocked(api.returnLoan).mockReset() }) function renderItem() { return render( - - } /> - library

} /> -
+ + + } /> + library

} /> +
+
) } @@ -142,4 +167,43 @@ describe('ItemPage', () => { await userEvent.click(await screen.findByRole('button', { name: /mark ripped/i })) await waitFor(() => expect(screen.getByText(/didn't work/i)).toBeTruthy()) }) + + it('shows Play for a ripped item with a matched album and loads the player', async () => { + vi.mocked(api.getItem).mockResolvedValue(rippedItem as never) + vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'alb-1', title: 'Motion', artist: 'TCO', tracks: [] } as never) + vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never) + renderItem() + const play = await screen.findByRole('button', { name: /play album/i }) + await userEvent.click(play) + await waitFor(() => expect(api.getAlbumTracks).toHaveBeenCalledWith('alb-1')) + await waitFor(() => expect(api.markPlayed).toHaveBeenCalledWith('alb-1')) + }) + + it('hides Play when unmatched or not ripped', async () => { + renderItem() + await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy()) + expect(screen.queryByRole('button', { name: /play album/i })).toBeNull() + }) + + it('shows last played under the rip banner', async () => { + vi.mocked(api.getItem).mockResolvedValue(rippedItem as never) + renderItem() + expect(await screen.findByText(/last played/i)).toBeTruthy() + }) + + it('lend and return flow', async () => { + vi.mocked(api.getItem) + .mockResolvedValueOnce(rippedItem as never) + .mockResolvedValueOnce({ ...rippedItem, loan: { id: 9, borrower: 'Bob', lentAt: '2026-09-03' } } as never) + vi.mocked(api.lendItem).mockResolvedValue({ id: 9, itemId: 1, borrower: 'Bob', lentAt: '2026-09-03', returnedAt: null } as never) + renderItem() + await userEvent.type(await screen.findByLabelText(/borrower/i), 'Bob') + await userEvent.click(screen.getByRole('button', { name: /^lend$/i })) + await waitFor(() => expect(api.lendItem).toHaveBeenCalledWith(1, 'Bob')) + + vi.mocked(api.returnLoan).mockResolvedValue({ ok: true } as never) + await waitFor(() => expect(screen.getByText(/out to bob/i)).toBeTruthy()) + await userEvent.click(screen.getByRole('button', { name: /mark returned/i })) + await waitFor(() => expect(api.returnLoan).toHaveBeenCalledWith(9)) + }) })