Add collector fields, including market value
Rating, notes, condition, region, purchase price and date, plus a market
value carrying the timestamp and source that make it interpretable.
Condition is load-bearing rather than cosmetic: price feeds quote per
condition, so it selects which quoted price applies to a copy. Market value
records when it was captured and where it came from — a collection total is
only as good as its staleness — and an edit to an unrelated field leaves
that timestamp alone, so a stale price cannot start looking freshly checked.
Two storage decisions worth naming:
* Money is stored as integer minor units. SQLite has no decimal type and
EF Core maps decimal to TEXT, which compares lexically: "9.00" sorts
above "10.00" and SUM is unavailable. A value converter keeps decimals
in C# while ordering and totalling work. A test pins the ordering.
* Enums serialise as names. The default is ordinals, which meant the API
rejected the browser's {"condition":"Cib"} with a 400 while the C# tests
passed, because they round-tripped ints and never spoke the client's
dialect. The tests now share the API's serializer options.
Also fixes a data-loss bug in the Python tools. Both built their PUT body
from a hardcoded list of field names, so any column added to the model was
omitted and therefore nulled. Adding collector fields meant the next art or
enrichment run would have erased every rating, note, condition, price and
valuation in the library. Payloads are now built by excluding the handful of
server-owned fields, so new columns carry through by default.
The migration was rehearsed against a copy of the live database before being
applied: 105 rows, descriptions and developers intact.
67 backend tests, 8 frontend.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,26 @@
|
||||
/** Mirrors the API contracts in backend/src/LudosData.Api/Contracts. */
|
||||
|
||||
/** Mirrors LudosData.Api.Domain.GameCondition. */
|
||||
export type GameCondition = 'Unspecified' | 'Loose' | 'Cib' | 'Sealed' | 'Digital';
|
||||
|
||||
/** Mirrors LudosData.Api.Domain.GameRegion. */
|
||||
export type GameRegion = 'Unspecified' | 'Ntsc' | 'Pal' | 'NtscJ';
|
||||
|
||||
export const CONDITIONS: { value: GameCondition; label: string }[] = [
|
||||
{ value: 'Unspecified', label: '—' },
|
||||
{ value: 'Loose', label: 'Loose (cart/disc only)' },
|
||||
{ value: 'Cib', label: 'Complete in box' },
|
||||
{ value: 'Sealed', label: 'Sealed' },
|
||||
{ value: 'Digital', label: 'Digital' },
|
||||
];
|
||||
|
||||
export const REGIONS: { value: GameRegion; label: string }[] = [
|
||||
{ value: 'Unspecified', label: '—' },
|
||||
{ value: 'Ntsc', label: 'NTSC (North America)' },
|
||||
{ value: 'Pal', label: 'PAL (Europe/Australia)' },
|
||||
{ value: 'NtscJ', label: 'NTSC-J (Japan)' },
|
||||
];
|
||||
|
||||
export interface Game {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -17,6 +38,22 @@ export interface Game {
|
||||
dumped: boolean;
|
||||
played: boolean;
|
||||
finished: boolean;
|
||||
|
||||
/** Personal score out of 10. Null means unrated, which is not zero. */
|
||||
rating: number | null;
|
||||
notes: string | null;
|
||||
condition: GameCondition;
|
||||
region: GameRegion;
|
||||
|
||||
/** What was paid. A fixed historical fact. */
|
||||
purchasePrice: number | null;
|
||||
purchaseDate: string | null;
|
||||
|
||||
/** Current estimated resale value, with when and where it came from. */
|
||||
marketValue: number | null;
|
||||
marketValueUpdatedAt: string | null;
|
||||
marketValueSource: string | null;
|
||||
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -35,6 +72,14 @@ export interface GameRequest {
|
||||
dumped: boolean;
|
||||
played: boolean;
|
||||
finished: boolean;
|
||||
rating: number | null;
|
||||
notes: string | null;
|
||||
condition: GameCondition;
|
||||
region: GameRegion;
|
||||
purchasePrice: number | null;
|
||||
purchaseDate: string | null;
|
||||
marketValue: number | null;
|
||||
marketValueSource: string | null;
|
||||
}
|
||||
|
||||
export interface PagedResult<T> {
|
||||
@@ -105,4 +150,12 @@ export const EMPTY_GAME: GameRequest = {
|
||||
dumped: false,
|
||||
played: false,
|
||||
finished: false,
|
||||
rating: null,
|
||||
notes: null,
|
||||
condition: 'Unspecified',
|
||||
region: 'Unspecified',
|
||||
purchasePrice: null,
|
||||
purchaseDate: null,
|
||||
marketValue: null,
|
||||
marketValueSource: null,
|
||||
};
|
||||
|
||||
@@ -116,6 +116,79 @@
|
||||
<mat-checkbox formControlName="finished">Finished</mat-checkbox>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="collector">
|
||||
<legend>Your copy</legend>
|
||||
|
||||
<div class="field-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Rating</mat-label>
|
||||
<mat-select formControlName="rating">
|
||||
<mat-option [value]="null">Unrated</mat-option>
|
||||
@for (score of [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]; track score) {
|
||||
<mat-option [value]="score">{{ score }} / 10</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Condition</mat-label>
|
||||
<mat-select formControlName="condition">
|
||||
@for (option of conditions; track option.value) {
|
||||
<mat-option [value]="option.value">{{ option.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Region</mat-label>
|
||||
<mat-select formControlName="region">
|
||||
@for (option of regions; track option.value) {
|
||||
<mat-option [value]="option.value">{{ option.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Notes</mat-label>
|
||||
<textarea matInput formControlName="notes" rows="2"
|
||||
placeholder="Anything you want to remember about this copy"></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="field-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Paid</mat-label>
|
||||
<span matTextPrefix>$ </span>
|
||||
<input matInput formControlName="purchasePrice" type="number" min="0" step="0.01" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Purchased</mat-label>
|
||||
<input matInput formControlName="purchaseDate" [matDatepicker]="purchasePicker" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="purchasePicker" />
|
||||
<mat-datepicker #purchasePicker />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Market value</mat-label>
|
||||
<span matTextPrefix>$ </span>
|
||||
<input matInput formControlName="marketValue" type="number" min="0" step="0.01" />
|
||||
@if (valuedAt(); as at) {
|
||||
<mat-hint>As of {{ at | date: 'mediumDate' }}</mat-hint>
|
||||
} @else {
|
||||
<mat-hint>What a copy sells for now</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Value source</mat-label>
|
||||
<input matInput formControlName="marketValueSource" placeholder="manual" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="editor-actions">
|
||||
<a mat-button routerLink="/games">Cancel</a>
|
||||
|
||||
|
||||
@@ -150,3 +150,19 @@
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.collector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem 0.5rem;
|
||||
margin: 0 0 1rem;
|
||||
|
||||
legend {
|
||||
padding-inline: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, computed, inject, input, signal } from '@angular/core';
|
||||
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { provideNativeDateAdapter } from '@angular/material/core';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
@@ -15,7 +18,13 @@ import { Router, RouterLink } from '@angular/router';
|
||||
|
||||
import { ConfirmDialog, ConfirmDialogData } from '../../shared/confirm-dialog';
|
||||
import { GamesService } from '../../core/games.service';
|
||||
import { GameRequest } from '../../core/models';
|
||||
import {
|
||||
CONDITIONS,
|
||||
GameCondition,
|
||||
GameRegion,
|
||||
GameRequest,
|
||||
REGIONS,
|
||||
} from '../../core/models';
|
||||
import { Toolbar } from '../../shared/toolbar';
|
||||
|
||||
/** Kept in sync with the values already present in the 2018 data. */
|
||||
@@ -44,7 +53,10 @@ const GENRES = [
|
||||
MatIconModule,
|
||||
MatProgressBarModule,
|
||||
MatDialogModule,
|
||||
MatDatepickerModule,
|
||||
DatePipe,
|
||||
],
|
||||
providers: [provideNativeDateAdapter()],
|
||||
templateUrl: './game-edit.html',
|
||||
styleUrl: './game-edit.scss',
|
||||
})
|
||||
@@ -70,6 +82,9 @@ export class GameEdit {
|
||||
/** Preview URL: a freshly-picked local file, or the stored art from the API. */
|
||||
protected readonly artPreview = signal<string | null>(null);
|
||||
|
||||
protected readonly conditions = CONDITIONS;
|
||||
protected readonly regions = REGIONS;
|
||||
|
||||
protected readonly form = this.fb.group({
|
||||
title: ['', [Validators.required, Validators.maxLength(200)]],
|
||||
system: [''],
|
||||
@@ -83,8 +98,20 @@ export class GameEdit {
|
||||
dumped: [false],
|
||||
played: [false],
|
||||
finished: [false],
|
||||
|
||||
rating: [null as number | null, [Validators.min(1), Validators.max(10)]],
|
||||
notes: [''],
|
||||
condition: ['Unspecified' as GameCondition],
|
||||
region: ['Unspecified' as GameRegion],
|
||||
purchasePrice: [null as number | null, Validators.min(0)],
|
||||
purchaseDate: [null as Date | null],
|
||||
marketValue: [null as number | null, Validators.min(0)],
|
||||
marketValueSource: [''],
|
||||
});
|
||||
|
||||
/** When the loaded valuation was captured, for the "as of" note. */
|
||||
protected readonly valuedAt = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
// input() is a signal, so this reacts if the route id ever changes without
|
||||
// the component being torn down.
|
||||
@@ -113,8 +140,17 @@ export class GameEdit {
|
||||
dumped: game.dumped,
|
||||
played: game.played,
|
||||
finished: game.finished,
|
||||
rating: game.rating,
|
||||
notes: game.notes ?? '',
|
||||
condition: game.condition,
|
||||
region: game.region,
|
||||
purchasePrice: game.purchasePrice,
|
||||
purchaseDate: game.purchaseDate ? new Date(game.purchaseDate) : null,
|
||||
marketValue: game.marketValue,
|
||||
marketValueSource: game.marketValueSource ?? '',
|
||||
});
|
||||
this.artPreview.set(game.artUrl);
|
||||
this.valuedAt.set(game.marketValueUpdatedAt);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
@@ -243,6 +279,27 @@ export class GameEdit {
|
||||
dumped: value.dumped,
|
||||
played: value.played,
|
||||
finished: value.finished,
|
||||
|
||||
rating: value.rating ?? null,
|
||||
notes: blankToNull(value.notes),
|
||||
condition: value.condition,
|
||||
region: value.region,
|
||||
purchasePrice: value.purchasePrice ?? null,
|
||||
// The date picker holds a local Date; send the calendar day only, so a
|
||||
// timezone west of UTC cannot shift the purchase back a day.
|
||||
purchaseDate: value.purchaseDate ? toIsoDate(value.purchaseDate) : null,
|
||||
marketValue: value.marketValue ?? null,
|
||||
marketValueSource: blankToNull(value.marketValueSource),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a picked date as a plain calendar day. toISOString() would convert to
|
||||
* UTC first, which moves the date back a day for anyone west of Greenwich.
|
||||
*/
|
||||
function toIsoDate(date: Date): string {
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${date.getDate()}`.padStart(2, '0');
|
||||
return `${date.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@
|
||||
<mat-option value="system">System</mat-option>
|
||||
<mat-option value="genre">Genre</mat-option>
|
||||
<mat-option value="year">Year</mat-option>
|
||||
<mat-option value="rating">Rating</mat-option>
|
||||
<mat-option value="value">Market value</mat-option>
|
||||
<mat-option value="price">Paid</mat-option>
|
||||
<mat-option value="purchased">Purchase date</mat-option>
|
||||
<mat-option value="created">Date added</mat-option>
|
||||
<mat-option value="updated">Last updated</mat-option>
|
||||
</mat-select>
|
||||
@@ -108,6 +112,15 @@
|
||||
<mat-icon>videogame_asset</mat-icon>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (game.rating) {
|
||||
<span class="rating" [attr.aria-label]="'Rated ' + game.rating + ' out of 10'">
|
||||
<mat-icon>star</mat-icon>{{ game.rating }}
|
||||
</span>
|
||||
}
|
||||
@if (game.marketValue !== null) {
|
||||
<span class="value">{{ game.marketValue | currency: 'USD' : 'symbol' : '1.0-0' }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
|
||||
@@ -183,3 +183,42 @@
|
||||
mat-paginator {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Rating and value sit over the art so a card stays the same height whether
|
||||
or not they are set. */
|
||||
.game-card .art {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.game-card .rating,
|
||||
.game-card .value {
|
||||
position: absolute;
|
||||
top: 0.375rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
/* Legible over any box art, light or dark. */
|
||||
background: rgb(0 0 0 / 0.72);
|
||||
color: #fff;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.game-card .rating {
|
||||
left: 0.375rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 0.75rem;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
color: #ffc93c;
|
||||
}
|
||||
}
|
||||
|
||||
.game-card .value {
|
||||
right: 0.375rem;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CurrencyPipe } from '@angular/common';
|
||||
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
@@ -17,7 +18,18 @@ import { GamesService } from '../../core/games.service';
|
||||
import { Facets, Game } from '../../core/models';
|
||||
import { Toolbar } from '../../shared/toolbar';
|
||||
|
||||
type SortKey = 'title' | 'system' | 'genre' | 'year' | 'created' | 'updated';
|
||||
/** Must stay in step with the allow-list in GamesController.ApplySort. */
|
||||
type SortKey =
|
||||
| 'title'
|
||||
| 'system'
|
||||
| 'genre'
|
||||
| 'year'
|
||||
| 'rating'
|
||||
| 'value'
|
||||
| 'price'
|
||||
| 'purchased'
|
||||
| 'created'
|
||||
| 'updated';
|
||||
|
||||
@Component({
|
||||
selector: 'app-game-grid',
|
||||
@@ -34,6 +46,7 @@ type SortKey = 'title' | 'system' | 'genre' | 'year' | 'created' | 'updated';
|
||||
MatPaginatorModule,
|
||||
MatProgressBarModule,
|
||||
MatChipsModule,
|
||||
CurrencyPipe,
|
||||
],
|
||||
templateUrl: './game-grid.html',
|
||||
styleUrl: './game-grid.scss',
|
||||
|
||||
Reference in New Issue
Block a user