Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The fields tracked per collection category. Decided in the Phase 1 spec discussi
| `OwnerId` | string (FK → AspNetUsers) | in code | Indexed; required filter on every read/write |
| `Title` | string (req, 500) | in code | Indexed |
| `Year` | int? | in code | Release / publication year |
| `Genres` | string? | in code | Comma-separated; future: normalize to a Genre table |
| `Genres` | many-to-many → `Genre` | in code | Relational, like Tags — see **Genres** below |
| `Barcode` | string? | in code | Indexed; key for Phase 3 scan |
| `ImagePath` | string? | in code | Local cover/poster path (downloaded in Phase 2) |
| `Notes` | string? | in code | Free-form |
Expand Down Expand Up @@ -103,6 +103,31 @@ Three EF-Core-managed join tables (auto-created via `HasMany(x => x.Tags).WithMa

API: `GET/POST/DELETE /api/tags`, plus `tags: string[]` on each item DTO that resolves on save (create-or-find by name).

## Genres

Many-to-many, scoped per owner — same shape as **Tags** above.

```csharp
public class Genre
{
public int Id { get; set; }
public string OwnerId { get; set; } = string.Empty; // indexed; genres are user-scoped
public string Name { get; set; } = string.Empty; // unique per (OwnerId, Name)
}
```

Three EF-Core-managed join tables (auto-created via `HasMany(x => x.Genres).WithMany()`):

- `GenreMovie(GenresId, MoviesId)`
- `GenreMusicAlbum(GenresId, MusicAlbumsId)`
- `GameGenre(GamesId, GenresId)`

`Genre.Name` is normalized lowercase on save (trim → lowercase → drop whitespace-only → distinct), mirroring `Tag.Name`.

API: `genres: string[]` on each item DTO, resolved on save (create-or-find by name), plus a bulk-update `genres` replace-set (`null` clears, malformed → 400). The list endpoints' `?genre=` filter is **exact membership**, not substring.

The migration that introduced this (`AddGenres`, owner decision 2026-08-24) was **schema-only**: the legacy comma-separated `Movies.Genres` / `MusicAlbums.Genres` string columns were dropped with no data backfill — existing genre values were discarded, not migrated.

## Store imports / provenance (in code — Steam first)

Backing tables for the "connect a digital store & import owned games" feature
Expand Down Expand Up @@ -217,4 +242,3 @@ Tracked separately from Phase 1 in its own GitHub issue (filed as a follow-up to
- **Goldmine media + sleeve grading for music** — sticking with one generic `Condition` field.
- **Region / Edition / Packaging / DiscCount / HasManual / HasBox / vinyl color / RPM / weight** — collector-tier fields, deferred.
- **Cast list for movies, tracklist for music, full company-roles for games** — providers expose these. Full provider payloads are not persisted in the application database. The memory cache is restart-ephemeral; opted-in Redis stores TTL-bounded payloads at rest and must be secured. A full re-fetch after cache expiry re-queries the provider.
- **Many-to-many `Genre`** — staying with CSV until a filter UI demands it.
25 changes: 25 additions & 0 deletions src/client/components/AlbumForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,28 @@ describe('AlbumForm — Fetch metadata by MusicBrainz Release ID', () => {
expect(screen.getByDisplayValue('f4e51c80-99e2-39e1-8062-c9b8e2685bdf')).toBeInTheDocument();
});
});

describe('AlbumForm — Genres', () => {
it('typing a genre renders it as a chip and submits it as an array', async () => {
const { onSubmit } = renderForm();
const user = userEvent.setup();

const genreInput = screen.getByPlaceholderText('Add genre…');
await user.type(genreInput, 'Rock');
await user.keyboard('{Enter}');

expect(screen.getByText('rock')).toBeInTheDocument();

const titleLabel = screen.getByText('Title');
const titleInput = titleLabel.parentElement?.querySelector('input') as HTMLInputElement;
await user.type(titleInput, 'Test Album');
const artistLabel = screen.getByText('Artist');
const artistInput = artistLabel.parentElement?.querySelector('input') as HTMLInputElement;
await user.type(artistInput, 'Test Artist');
await user.click(screen.getByRole('button', { name: 'Save' }));

expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ genres: ['rock'] }),
);
});
});
5 changes: 3 additions & 2 deletions src/client/components/AlbumForm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Button, CoverPreview, ExternalIdField, Field, Input, SectionHeading, Select, Textarea } from './ui';
import { Button, CoverPreview, ExternalIdField, Field, Input, SectionHeading, Select, TagInput, Textarea } from './ui';
import CoverEditor from './CoverEditor';
import CoverFormLayout from './CoverFormLayout';
import PersonalAcquisitionSection from './PersonalAcquisitionSection';
Expand Down Expand Up @@ -37,6 +37,7 @@ const empty: Album = {
status: 'Owned',
listenCount: 0,
tags: [],
genres: [],
};

export default function AlbumForm({ initial, prefillLookup, prefillBarcode, submitting, submitLabel = 'Save', onSubmit, onDelete }: Props) {
Expand Down Expand Up @@ -133,7 +134,7 @@ export default function AlbumForm({ initial, prefillLookup, prefillBarcode, subm
<Input value={a.label ?? ''} onChange={(e) => set('label', e.target.value || null)} />
</Field>
<Field label="Genres">
<Input value={a.genres ?? ''} onChange={(e) => set('genres', e.target.value || null)} />
<TagInput value={a.genres ?? []} onChange={(next) => set('genres', next)} placeholder="Add genre…" />
</Field>
<Field label="Barcode">
<Input value={a.barcode ?? ''} onChange={(e) => set('barcode', e.target.value || null)} />
Expand Down
22 changes: 22 additions & 0 deletions src/client/components/CollectionList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,28 @@ describe('CollectionList — bulk select + update', () => {
});
});

it('exposes a Genres field in the bulk modal, mapped to updates.genres', async () => {
const fetchSpy = mockFetch();
renderList();
const user = userEvent.setup();

await screen.findByText('Inception');
const checkboxes = screen.getAllByLabelText('Select item');
await user.click(checkboxes[0]);
await user.click(screen.getByRole('button', { name: 'Edit selected' }));

expect(screen.getByText('Genres')).toBeInTheDocument();
await user.type(screen.getByPlaceholderText('Add genre…'), 'Sci-Fi{Enter}');
await user.click(screen.getByRole('button', { name: 'Confirm' }));

await waitFor(() => {
const bulkCall = fetchSpy.mock.calls.find(([u]) => String(u) === '/api/movies/bulk');
expect(bulkCall).toBeDefined();
});
const [, init] = fetchSpy.mock.calls.find(([u]) => String(u) === '/api/movies/bulk')!;
expect(JSON.parse(init!.body as string)).toEqual({ ids: [1], updates: { genres: ['sci-fi'] } });
});

it('Confirm is disabled until a field is set, and enables once one is', async () => {
const fetchSpy = mockFetch();
renderList();
Expand Down
9 changes: 8 additions & 1 deletion src/client/components/CollectionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ export default function CollectionList<T extends MediaType>({ type, title, newPa
const [bulkStatus, setBulkStatus] = useState<CollectionStatus | ''>('');
const [bulkRating, setBulkRating] = useState<number | null>(null);
const [bulkTags, setBulkTags] = useState<string[]>([]);
const [bulkGenres, setBulkGenres] = useState<string[]>([]);
const [bulkAcquiredOn, setBulkAcquiredOn] = useState('');
const [bulkWatchStatus, setBulkWatchStatus] = useState<WatchStatus | ''>('');

Expand All @@ -277,14 +278,15 @@ export default function CollectionList<T extends MediaType>({ type, title, newPa
setBulkStatus('');
setBulkRating(null);
setBulkTags([]);
setBulkGenres([]);
setBulkAcquiredOn('');
setBulkWatchStatus('');
setBulkModalOpen(true);
};
const closeBulkEdit = () => setBulkModalOpen(false);

const hasBulkEdit = Boolean(
bulkStatus || bulkRating != null || bulkTags.length > 0 || bulkAcquiredOn
bulkStatus || bulkRating != null || bulkTags.length > 0 || bulkGenres.length > 0 || bulkAcquiredOn
|| (category === 'movies' && bulkWatchStatus),
);

Expand All @@ -293,6 +295,7 @@ export default function CollectionList<T extends MediaType>({ type, title, newPa
if (bulkStatus) updates.status = bulkStatus;
if (bulkRating != null) updates.personalRating = bulkRating;
if (bulkTags.length > 0) updates.tags = bulkTags;
if (bulkGenres.length > 0) updates.genres = bulkGenres;
if (bulkAcquiredOn) updates.acquiredOn = bulkAcquiredOn;
if (category === 'movies' && bulkWatchStatus) updates.watchStatus = bulkWatchStatus;

Expand Down Expand Up @@ -423,6 +426,10 @@ export default function CollectionList<T extends MediaType>({ type, title, newPa
<TagInput value={bulkTags} onChange={setBulkTags} category={category} />
</Field>

<Field label="Genres">
<TagInput value={bulkGenres} onChange={setBulkGenres} category={category} placeholder="Add genre…" />
</Field>

<Field label="Acquired on">
<Input type="date" value={bulkAcquiredOn} onChange={(e) => setBulkAcquiredOn(e.target.value)} />
</Field>
Expand Down
10 changes: 9 additions & 1 deletion src/client/components/FiltersPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import type { Filters } from '../services/filters';
Expand Down Expand Up @@ -26,6 +26,14 @@ describe('FiltersPanel', () => {
expect(onChange).toHaveBeenCalledWith({ director: 'Nolan', tag: ['imax', 'sci-fi'] });
});

it('sends the exact genre value entered, not a partial token', async () => {
const onChange = renderPanel('movies', { director: 'Nolan' });
await userEvent.click(screen.getByRole('button', { name: /Filters/ }));
fireEvent.change(screen.getByPlaceholderText('exact match'), { target: { value: 'sci-fi' } });

expect(onChange).toHaveBeenLastCalledWith({ director: 'Nolan', genre: 'sci-fi' });
});

it('counts one rendered year-range chip as one active filter', () => {
renderPanel('movies', { yearFrom: 2000, yearTo: 2020 });
expect(screen.getByRole('button', { name: /Filters \(1\)/ })).toBeInTheDocument();
Expand Down
7 changes: 5 additions & 2 deletions src/client/components/FiltersPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ function MovieFields({ value, onChange }: { value: Filters<'movies'>; onChange:
<Input value={value.studio ?? ''} onChange={(e) => set('studio', e.target.value || undefined)} />
</Field>
<Field label="Genre">
<Input value={value.genre ?? ''} onChange={(e) => set('genre', e.target.value || undefined)} placeholder="substring match" />
<Input value={value.genre ?? ''} onChange={(e) => set('genre', e.target.value || undefined)} placeholder="exact match" />
</Field>
<Field label="Format">
<Select value={value.format ?? ''} onChange={(e) => set('format', (e.target.value || undefined) as Filters<'movies'>['format'])}>
Expand Down Expand Up @@ -140,7 +140,7 @@ function AlbumFields({ value, onChange }: { value: Filters<'music'>; onChange: (
<Input value={value.label ?? ''} onChange={(e) => set('label', e.target.value || undefined)} />
</Field>
<Field label="Genre">
<Input value={value.genre ?? ''} onChange={(e) => set('genre', e.target.value || undefined)} placeholder="substring match" />
<Input value={value.genre ?? ''} onChange={(e) => set('genre', e.target.value || undefined)} placeholder="exact match" />
</Field>
<Field label="Format">
<Select value={value.format ?? ''} onChange={(e) => set('format', (e.target.value || undefined) as Filters<'music'>['format'])}>
Expand Down Expand Up @@ -203,6 +203,9 @@ function GameFields({ value, onChange }: { value: Filters<'games'>; onChange: (n
<Field label="Developer">
<Input value={value.developer ?? ''} onChange={(e) => set('developer', e.target.value || undefined)} />
</Field>
<Field label="Genre">
<Input value={value.genre ?? ''} onChange={(e) => set('genre', e.target.value || undefined)} placeholder="exact match" />
</Field>
<Field label="Status">
<Select value={value.status ?? ''} onChange={(e) => set('status', (e.target.value || undefined) as Filters<'games'>['status'])}>
<option value="">Any</option>
Expand Down
8 changes: 7 additions & 1 deletion src/client/components/GameDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ export default function GameDetail({ item }: { item: Game }) {
<ThemedCard type="games" className="p-4"><h3>Platform</h3><p>{gamePlatformLabel(item.platform)} · {item.digitalStores ? 'Digital' : 'Physical'}</p></ThemedCard>
{(item.releaseDate || item.year) && <ThemedCard type="games" className="p-4"><h3>Release</h3><InfoRow label="Release date" value={formatDate(item.releaseDate)} /><InfoRow label="Year" value={item.year?.toString()} /></ThemedCard>}
{(item.developer || item.publisher) && <ThemedCard type="games" className="p-4"><h3>Credits</h3><InfoRow label="Developer" value={item.developer} /><InfoRow label="Publisher" value={item.publisher} /></ThemedCard>}
{item.ageRating && <ThemedCard type="games" className="p-4"><h3>Metadata</h3><InfoRow label="Age rating" value={item.ageRating} /></ThemedCard>}
{(item.ageRating || !!item.genres?.length) && (
<ThemedCard type="games" className="p-4">
<h3>Metadata</h3>
<InfoRow label="Age rating" value={item.ageRating} />
{!!item.genres?.length && <div>{item.genres.map((genre) => <TagChip key={genre} name={genre} category="games" />)}</div>}
</ThemedCard>
)}
{(item.barcode || item.igdbId) && (
<ThemedCard type="games" className="p-4">
<h3>IDs</h3>
Expand Down
17 changes: 17 additions & 0 deletions src/client/components/GameForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,20 @@ describe('GameForm — digital store buttons', () => {
expect(screen.getByRole('button', { name: 'GOG' })).toHaveAttribute('aria-pressed', 'false');
});
});

describe('GameForm — Genres', () => {
it('typing a genre renders it as a chip and submits it as an array', () => {
const onSubmit = vi.fn();
renderForm(onSubmit, pcGame);

const genreInput = screen.getByPlaceholderText('Add genre…');
fireEvent.change(genreInput, { target: { value: 'Action' } });
fireEvent.keyDown(genreInput, { key: 'Enter' });

expect(screen.getByText('action')).toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: 'Save' }));
const s = onSubmit.mock.calls[0][0] as Game;
expect(s.genres).toEqual(['action']);
});
});
5 changes: 5 additions & 0 deletions src/client/components/GameForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
SearchableSelect,
SectionHeading,
Select,
TagInput,
Textarea,
} from './ui';
import { PlatformIcon } from './FormatIcons';
Expand Down Expand Up @@ -56,6 +57,7 @@ const empty: Game = {
status: 'Owned',
completionStatus: 'NotStarted',
tags: [],
genres: [],
};

export default function GameForm({ initial, prefillLookup, prefillBarcode, submitting, submitLabel = 'Save', onSubmit, onDelete }: Props) {
Expand Down Expand Up @@ -197,6 +199,9 @@ export default function GameForm({ initial, prefillLookup, prefillBarcode, submi
<Field label="Developer">
<Input value={g.developer ?? ''} onChange={(e) => set('developer', e.target.value || null)} />
</Field>
<Field label="Genres">
<TagInput value={g.genres ?? []} onChange={(next) => set('genres', next)} placeholder="Add genre…" />
</Field>
<Field label="Barcode">
<Input value={g.barcode ?? ''} onChange={(e) => set('barcode', e.target.value || null)} />
</Field>
Expand Down
10 changes: 8 additions & 2 deletions src/client/components/MovieDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,19 @@ export default function MovieDetail({ item }: { item: Movie }) {
</dl>
</ThemedCard>
)}
{(item.studio || item.genres || fs.length > 0) && (
{(item.studio || item.genres?.length || fs.length > 0) && (
<ThemedCard type="movies" className="p-4">
<h3>Metadata</h3>
<dl>
<InfoRow label="Studio" value={item.studio} />
<InfoRow label="Genres" value={item.genres} />
</dl>
{!!item.genres?.length && (
<div>
{item.genres.map((genre) => (
<TagChip key={genre} name={genre} category="movies" />
))}
</div>
)}
</ThemedCard>
)}
{(item.barcode || item.tmdbId || item.imdbId) && (
Expand Down
22 changes: 22 additions & 0 deletions src/client/components/MovieForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,3 +276,25 @@ describe('MovieForm — Fetch metadata by TMDB ID', () => {
expect(mockLookupMovieById).toHaveBeenCalledWith('27205');
});
});

describe('MovieForm — Genres', () => {
it('typing a genre renders it as a chip and submits it as an array', async () => {
const { onSubmit } = renderForm();
const user = userEvent.setup();

const genreInput = screen.getByPlaceholderText('Add genre…');
await user.type(genreInput, 'Sci-Fi');
await user.keyboard('{Enter}');

expect(screen.getByText('sci-fi')).toBeInTheDocument();

const titleLabel = screen.getByText('Title');
const titleInput = titleLabel.parentElement?.querySelector('input') as HTMLInputElement;
await user.type(titleInput, 'Test');
await user.click(screen.getByRole('button', { name: 'Save' }));

expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ genres: ['sci-fi'] }),
);
});
});
7 changes: 4 additions & 3 deletions src/client/components/MovieForm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Button, CoverPreview, ExternalIdField, Field, Input, SectionHeading, Select, Textarea } from './ui';
import { Button, CoverPreview, ExternalIdField, Field, Input, SectionHeading, Select, TagInput, Textarea } from './ui';
import CoverEditor from './CoverEditor';
import CoverFormLayout from './CoverFormLayout';
import PersonalAcquisitionSection from './PersonalAcquisitionSection';
Expand Down Expand Up @@ -40,6 +40,7 @@ const empty: Movie = {
watchStatus: 'Unwatched',
watchCount: 0,
tags: [],
genres: [],
};

export default function MovieForm({ initial, prefillLookup, prefillBarcode, submitting, submitLabel = 'Save', onSubmit, onDelete }: Props) {
Expand Down Expand Up @@ -137,8 +138,8 @@ export default function MovieForm({ initial, prefillLookup, prefillBarcode, subm
<Field label="Studio">
<Input value={m.studio ?? ''} onChange={(e) => set('studio', e.target.value || null)} />
</Field>
<Field label="Genres (comma separated)">
<Input value={m.genres ?? ''} onChange={(e) => set('genres', e.target.value || null)} />
<Field label="Genres">
<TagInput value={m.genres ?? []} onChange={(next) => set('genres', next)} placeholder="Add genre…" />
</Field>
<Field label="Barcode">
<Input value={m.barcode ?? ''} onChange={(e) => set('barcode', e.target.value || null)} />
Expand Down
10 changes: 8 additions & 2 deletions src/client/components/MusicDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,17 @@ export default function MusicDetail({ item }: { item: Album }) {
</p>
)}
</ThemedCard>
{(item.genres || item.label) && (
{(item.genres?.length || item.label) && (
<ThemedCard type="music" className="p-4">
<h3>Metadata</h3>
<InfoRow label="Genre" value={item.genres} />
<InfoRow label="Label" value={item.label} />
{!!item.genres?.length && (
<div>
{item.genres.map((genre) => (
<TagChip key={genre} name={genre} category="music" />
))}
</div>
)}
</ThemedCard>
)}
{(item.barcode || item.musicBrainzReleaseId || item.discogsId) && (
Expand Down
1 change: 1 addition & 0 deletions src/client/services/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export interface BulkUpdates {
description?: string | null;
notes?: string | null;
tags?: string[];
genres?: string[];
// movies
watchStatus?: WatchStatus;
watchCount?: number;
Expand Down
Loading
Loading