From d5a0e42fed968b675fdfdad10a8e026825aedaf8 Mon Sep 17 00:00:00 2001 From: Christopher Koch Date: Tue, 4 Aug 2026 13:29:48 -0400 Subject: [PATCH] Add collector fields, including market value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 27 ++ .../LudosData.Api/Contracts/GameContracts.cs | 43 +- .../Contracts/LibraryContracts.cs | 14 + .../Controllers/GamesController.cs | 44 +- .../Controllers/LibraryController.cs | 81 +++- .../src/LudosData.Api/Data/LudosDbContext.cs | 18 + ...60804171435_AddCollectorFields.Designer.cs | 397 ++++++++++++++++++ .../20260804171435_AddCollectorFields.cs | 121 ++++++ .../Migrations/LudosDbContextModelSnapshot.cs | 30 ++ backend/src/LudosData.Api/Domain/Enums.cs | 34 ++ backend/src/LudosData.Api/Domain/Game.cs | 35 ++ backend/src/LudosData.Api/Program.cs | 12 +- .../tests/LudosData.Api.Tests/AuthTests.cs | 32 +- .../CollectorFieldTests.cs | 325 ++++++++++++++ .../tests/LudosData.Api.Tests/GamesTests.cs | 60 +-- backend/tests/LudosData.Api.Tests/HttpJson.cs | 33 ++ .../tests/LudosData.Api.Tests/LibraryTests.cs | 40 +- .../LudosData.Api.Tests/LudosApiFactory.cs | 4 +- .../LudosData.Api.Tests/OwnershipTests.cs | 30 +- frontend/src/app/core/models.ts | 53 +++ .../src/app/features/game-edit/game-edit.html | 73 ++++ .../src/app/features/game-edit/game-edit.scss | 16 + .../src/app/features/game-edit/game-edit.ts | 59 ++- .../src/app/features/game-grid/game-grid.html | 13 + .../src/app/features/game-grid/game-grid.scss | 39 ++ .../src/app/features/game-grid/game-grid.ts | 15 +- .../__pycache__/fetch_art.cpython-314.pyc | Bin 30592 -> 31113 bytes tools/library/enrich_metadata.py | 8 +- tools/library/fetch_art.py | 34 +- 29 files changed, 1585 insertions(+), 105 deletions(-) create mode 100644 backend/src/LudosData.Api/Data/Migrations/20260804171435_AddCollectorFields.Designer.cs create mode 100644 backend/src/LudosData.Api/Data/Migrations/20260804171435_AddCollectorFields.cs create mode 100644 backend/src/LudosData.Api/Domain/Enums.cs create mode 100644 backend/tests/LudosData.Api.Tests/CollectorFieldTests.cs create mode 100644 backend/tests/LudosData.Api.Tests/HttpJson.cs diff --git a/README.md b/README.md index a04068d..dadcb4a 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,33 @@ three entries. `Merge` adds and updates but never deletes; `Replace` wipes the library first and is confirmed twice in the UI. `dryRun` reports exactly what would happen and writes nothing. +### Collector fields + +Beyond the four original flags, each game carries rating (1-10), notes, +condition, region, what you paid and when, and a current market value. + +`condition` is not cosmetic: price feeds quote per condition, and the gap +between loose and sealed is routinely a multiple, so it selects which quoted +price applies to a copy. + +Market value is stored with **when it was captured** and **where it came from**. +A figure with neither is not something you can reason about, and a collection +total is only as good as its staleness. Editing an unrelated field leaves the +timestamp alone; changing the figure moves it. A future price feed writes the +same three columns. + +**Money is stored as integer minor units.** SQLite has no decimal type, and EF +Core's default maps `decimal` to TEXT, which compares lexically — `"9.00"` sorts +above `"10.00"`, and SUM does not work at all. A value converter keeps the C# +side as `decimal` while ordering and totalling behave. + +**Enums travel as names, not ordinals.** `"Cib"` is self-describing in a payload, +an export and a log line; `2` is not, and renumbering the enum would silently +reinterpret every stored export. + +New query parameters: `condition`, `region`, `minRating`, `hasValue`. New sort +keys: `rating`, `value`, `price`, `purchased`. + ### Database changes ```bash diff --git a/backend/src/LudosData.Api/Contracts/GameContracts.cs b/backend/src/LudosData.Api/Contracts/GameContracts.cs index 98a4a64..206cf09 100644 --- a/backend/src/LudosData.Api/Contracts/GameContracts.cs +++ b/backend/src/LudosData.Api/Contracts/GameContracts.cs @@ -1,5 +1,7 @@ using System.ComponentModel.DataAnnotations; +using LudosData.Api.Domain; + namespace LudosData.Api.Contracts; /// A page of results plus the totals the paginator needs. @@ -28,6 +30,15 @@ public record GameResponse( bool Dumped, bool Played, bool Finished, + int? Rating, + string? Notes, + GameCondition Condition, + GameRegion Region, + decimal? PurchasePrice, + DateOnly? PurchaseDate, + decimal? MarketValue, + DateTimeOffset? MarketValueUpdatedAt, + string? MarketValueSource, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); @@ -52,6 +63,24 @@ public record GameRequest public bool Dumped { get; init; } public bool Played { get; init; } public bool Finished { get; init; } + + [Range(1, 10)] public int? Rating { get; init; } + [MaxLength(10_000)] public string? Notes { get; init; } + + public GameCondition Condition { get; init; } = GameCondition.Unspecified; + public GameRegion Region { get; init; } = GameRegion.Unspecified; + + [Range(0, 1_000_000)] public decimal? PurchasePrice { get; init; } + public DateOnly? PurchaseDate { get; init; } + + /// + /// Current estimated resale value. Accepted here so a figure can be entered + /// by hand; a price feed will later write the same field, stamping + /// MarketValueUpdatedAt and MarketValueSource as it goes. + /// + [Range(0, 1_000_000)] public decimal? MarketValue { get; init; } + + [MaxLength(100)] public string? MarketValueSource { get; init; } } /// Query string for the library list, bound from [FromQuery]. @@ -68,12 +97,24 @@ public record GameQuery public bool? Played { get; init; } public bool? Finished { get; init; } + public GameCondition? Condition { get; init; } + public GameRegion? Region { get; init; } + + /// Lowest personal score to include. Unrated games are excluded when set. + [Range(1, 10)] public int? MinRating { get; init; } + + /// Restrict to games that do, or do not, have a market value recorded. + public bool? HasValue { get; init; } + [Range(1, int.MaxValue)] public int Page { get; init; } = 1; /// Capped at 100 to keep a hostile or buggy client from asking for everything. [Range(1, 100)] public int PageSize { get; init; } = 20; - /// One of: title, system, genre, year, developer, publisher, created, updated. + /// + /// One of: title, system, genre, year, developer, publisher, rating, + /// value, price, purchased, created, updated. + /// public string Sort { get; init; } = "title"; /// "asc" or "desc". diff --git a/backend/src/LudosData.Api/Contracts/LibraryContracts.cs b/backend/src/LudosData.Api/Contracts/LibraryContracts.cs index 9d443d0..19e054b 100644 --- a/backend/src/LudosData.Api/Contracts/LibraryContracts.cs +++ b/backend/src/LudosData.Api/Contracts/LibraryContracts.cs @@ -1,3 +1,5 @@ +using LudosData.Api.Domain; + namespace LudosData.Api.Contracts; /// @@ -28,6 +30,18 @@ public record ExportGame public bool Dumped { get; init; } public bool Played { get; init; } public bool Finished { get; init; } + + // Collector fields travel with the export; a backup that quietly dropped + // ratings, notes and valuations would not be a backup. + public int? Rating { get; init; } + public string? Notes { get; init; } + public GameCondition Condition { get; init; } + public GameRegion Region { get; init; } + public decimal? PurchasePrice { get; init; } + public DateOnly? PurchaseDate { get; init; } + public decimal? MarketValue { get; init; } + public DateTimeOffset? MarketValueUpdatedAt { get; init; } + public string? MarketValueSource { get; init; } } /// Envelope written by the JSON exporter. diff --git a/backend/src/LudosData.Api/Controllers/GamesController.cs b/backend/src/LudosData.Api/Controllers/GamesController.cs index 8d86afd..3eb71ee 100644 --- a/backend/src/LudosData.Api/Controllers/GamesController.cs +++ b/backend/src/LudosData.Api/Controllers/GamesController.cs @@ -48,6 +48,18 @@ public class GamesController( if (query.Played is { } played) q = q.Where(g => g.Played == played); if (query.Finished is { } finished) q = q.Where(g => g.Finished == finished); + if (query.Condition is { } condition) q = q.Where(g => g.Condition == condition); + if (query.Region is { } region) q = q.Where(g => g.Region == region); + + // An unrated game is not a zero-rated one, so it drops out of a + // minimum-rating filter rather than sorting to the bottom. + if (query.MinRating is { } minRating) q = q.Where(g => g.Rating >= minRating); + + if (query.HasValue is { } hasValue) + { + q = hasValue ? q.Where(g => g.MarketValue != null) : q.Where(g => g.MarketValue == null); + } + var total = await q.CountAsync(ct); q = ApplySort(q, query.Sort, query.Dir); @@ -150,6 +162,10 @@ public class GamesController( "year" => descending ? q.OrderByDescending(g => g.Year) : q.OrderBy(g => g.Year), "developer" => descending ? q.OrderByDescending(g => g.Developer) : q.OrderBy(g => g.Developer), "publisher" => descending ? q.OrderByDescending(g => g.Publisher) : q.OrderBy(g => g.Publisher), + "rating" => descending ? q.OrderByDescending(g => g.Rating) : q.OrderBy(g => g.Rating), + "value" => descending ? q.OrderByDescending(g => g.MarketValue) : q.OrderBy(g => g.MarketValue), + "price" => descending ? q.OrderByDescending(g => g.PurchasePrice) : q.OrderBy(g => g.PurchasePrice), + "purchased" => descending ? q.OrderByDescending(g => g.PurchaseDate) : q.OrderBy(g => g.PurchaseDate), "created" => descending ? q.OrderByDescending(g => g.CreatedAt) : q.OrderBy(g => g.CreatedAt), "updated" => descending ? q.OrderByDescending(g => g.UpdatedAt) : q.OrderBy(g => g.UpdatedAt), _ => descending ? q.OrderByDescending(g => g.Title) : q.OrderBy(g => g.Title), @@ -170,10 +186,36 @@ public class GamesController( game.Dumped = request.Dumped; game.Played = request.Played; game.Finished = request.Finished; + + game.Rating = request.Rating; + game.Notes = request.Notes; + game.Condition = request.Condition; + game.Region = request.Region; + game.PurchasePrice = request.PurchasePrice; + game.PurchaseDate = request.PurchaseDate; + + // Only stamp the valuation when the figure actually changes, so an + // unrelated edit does not make a stale price look freshly checked. + if (request.MarketValue != game.MarketValue) + { + game.MarketValue = request.MarketValue; + game.MarketValueUpdatedAt = request.MarketValue is null ? null : DateTimeOffset.UtcNow; + game.MarketValueSource = request.MarketValue is null + ? null + : request.MarketValueSource?.Trim() ?? "manual"; + } + else if (request.MarketValueSource is { } source && game.MarketValue is not null) + { + game.MarketValueSource = source.Trim(); + } } private GameResponse ToResponse(Game g, string ownerId) => new( g.Id, g.Title, g.System, g.Genre, g.Year, g.Developer, g.Publisher, g.Art, images.BuildUrl(ownerId, g.Art), g.Description, - g.Own, g.Dumped, g.Played, g.Finished, g.CreatedAt, g.UpdatedAt); + g.Own, g.Dumped, g.Played, g.Finished, + g.Rating, g.Notes, g.Condition, g.Region, + g.PurchasePrice, g.PurchaseDate, + g.MarketValue, g.MarketValueUpdatedAt, g.MarketValueSource, + g.CreatedAt, g.UpdatedAt); } diff --git a/backend/src/LudosData.Api/Controllers/LibraryController.cs b/backend/src/LudosData.Api/Controllers/LibraryController.cs index 359bf86..f280fa1 100644 --- a/backend/src/LudosData.Api/Controllers/LibraryController.cs +++ b/backend/src/LudosData.Api/Controllers/LibraryController.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text; using System.Text.Json; using LudosData.Api.Auth; @@ -30,10 +31,18 @@ public class LibraryController( [ "title", "system", "genre", "year", "developer", "publisher", "description", "art", "own", "dumped", "played", "finished", + "rating", "notes", "condition", "region", + "purchasePrice", "purchaseDate", "marketValue", "marketValueUpdatedAt", + "marketValueSource", ]; - private static readonly JsonSerializerOptions JsonOptions = - new(JsonSerializerDefaults.Web) { WriteIndented = true }; + // Must match the converter registered on the controllers, so an export + // written with enum names is readable by the importer. + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, + }; // ---- export ---------------------------------------------------------- @@ -56,6 +65,16 @@ public class LibraryController( g.Title, g.System, g.Genre, g.Year, g.Developer, g.Publisher, g.Description, g.Art, g.Own.ToString(), g.Dumped.ToString(), g.Played.ToString(), g.Finished.ToString(), + g.Rating?.ToString(), g.Notes, + g.Condition == GameCondition.Unspecified ? null : g.Condition.ToString(), + g.Region == GameRegion.Unspecified ? null : g.Region.ToString(), + // Invariant culture throughout: a comma decimal separator would + // collide with the delimiter, and dates must not depend on locale. + g.PurchasePrice?.ToString(CultureInfo.InvariantCulture), + g.PurchaseDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + g.MarketValue?.ToString(CultureInfo.InvariantCulture), + g.MarketValueUpdatedAt?.ToString("O", CultureInfo.InvariantCulture), + g.MarketValueSource, })); // A BOM keeps Excel from mangling non-ASCII titles such as Pokémon. @@ -265,12 +284,48 @@ public class LibraryController( Dumped = Flag(row, "dumped"), Played = Flag(row, "played"), Finished = Flag(row, "finished"), + + Rating = ParseInt(Field(row, "rating")), + Notes = Field(row, "notes"), + Condition = ParseEnum(Field(row, "condition")), + Region = ParseEnum(Field(row, "region")), + PurchasePrice = ParseMoney(Field(row, "purchaseprice")), + PurchaseDate = ParseDate(Field(row, "purchasedate")), + MarketValue = ParseMoney(Field(row, "marketvalue")), + MarketValueUpdatedAt = ParseTimestamp(Field(row, "marketvalueupdatedat")), + MarketValueSource = Field(row, "marketvaluesource"), }); } return games; } + // Parsers are forgiving: a spreadsheet round-trip is a normal way for these + // files to arrive, and one unreadable cell should not cost the whole row. + private static int? ParseInt(string? value) => + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) + ? parsed : null; + + private static decimal? ParseMoney(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + // Tolerate a currency symbol and thousands separators from a spreadsheet. + var cleaned = value.Trim().TrimStart('$', '£', '€').Replace(",", string.Empty); + return decimal.TryParse(cleaned, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed) + ? parsed : null; + } + + private static DateOnly? ParseDate(string? value) => + DateOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) + ? parsed : null; + + private static DateTimeOffset? ParseTimestamp(string? value) => + DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed) + ? parsed : null; + + private static T ParseEnum(string? value) where T : struct, Enum => + Enum.TryParse(value, ignoreCase: true, out var parsed) ? parsed : default; + private static string Key(string title, string? system) => $"{title.Trim().ToLowerInvariant()}{(system ?? string.Empty).Trim().ToLowerInvariant()}"; @@ -288,6 +343,19 @@ public class LibraryController( target.Dumped = source.Dumped; target.Played = source.Played; target.Finished = source.Finished; + + target.Rating = source.Rating; + target.Notes = Blank(source.Notes); + target.Condition = source.Condition; + target.Region = source.Region; + target.PurchasePrice = source.PurchasePrice; + target.PurchaseDate = source.PurchaseDate; + + // The valuation's own timestamp is restored as recorded rather than + // reset to now: an import is a restore, not a fresh price check. + target.MarketValue = source.MarketValue; + target.MarketValueUpdatedAt = source.MarketValue is null ? null : source.MarketValueUpdatedAt; + target.MarketValueSource = source.MarketValue is null ? null : Blank(source.MarketValueSource); } private static string? Blank(string? value) => @@ -307,5 +375,14 @@ public class LibraryController( Dumped = g.Dumped, Played = g.Played, Finished = g.Finished, + Rating = g.Rating, + Notes = g.Notes, + Condition = g.Condition, + Region = g.Region, + PurchasePrice = g.PurchasePrice, + PurchaseDate = g.PurchaseDate, + MarketValue = g.MarketValue, + MarketValueUpdatedAt = g.MarketValueUpdatedAt, + MarketValueSource = g.MarketValueSource, }; } diff --git a/backend/src/LudosData.Api/Data/LudosDbContext.cs b/backend/src/LudosData.Api/Data/LudosDbContext.cs index e708696..a550fe7 100644 --- a/backend/src/LudosData.Api/Data/LudosDbContext.cs +++ b/backend/src/LudosData.Api/Data/LudosDbContext.cs @@ -1,6 +1,7 @@ using LudosData.Api.Domain; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace LudosData.Api.Data; @@ -13,6 +14,15 @@ public class LudosDbContext(DbContextOptions options) { base.OnModelCreating(builder); + // SQLite has no decimal type. EF Core's default is to store decimal as + // TEXT, which compares lexically — "9.00" sorts above "10.00", and SUM + // is not available at all. Money is therefore stored as integer minor + // units and converted on the way in and out, so ordering by value and + // totalling a collection both behave. + var moneyToCents = new ValueConverter( + value => value == null ? null : (long)Math.Round(value.Value * 100m, MidpointRounding.AwayFromZero), + cents => cents == null ? null : cents.Value / 100m); + builder.Entity(game => { game.HasOne(g => g.Owner) @@ -20,11 +30,19 @@ public class LudosDbContext(DbContextOptions options) .HasForeignKey(g => g.OwnerId) .OnDelete(DeleteBehavior.Cascade); + game.Property(g => g.PurchasePrice).HasConversion(moneyToCents); + game.Property(g => g.MarketValue).HasConversion(moneyToCents); + + // Stored as an enum's underlying int; readable names live in the API. + game.Property(g => g.Condition).HasConversion(); + game.Property(g => g.Region).HasConversion(); + // Every list query filters by owner first, then narrows or sorts on // these columns, so they lead the composite indexes. game.HasIndex(g => new { g.OwnerId, g.Title }); game.HasIndex(g => new { g.OwnerId, g.System }); game.HasIndex(g => new { g.OwnerId, g.Genre }); + game.HasIndex(g => new { g.OwnerId, g.Rating }); }); } diff --git a/backend/src/LudosData.Api/Data/Migrations/20260804171435_AddCollectorFields.Designer.cs b/backend/src/LudosData.Api/Data/Migrations/20260804171435_AddCollectorFields.Designer.cs new file mode 100644 index 0000000..684460e --- /dev/null +++ b/backend/src/LudosData.Api/Data/Migrations/20260804171435_AddCollectorFields.Designer.cs @@ -0,0 +1,397 @@ +// +using System; +using LudosData.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LudosData.Api.Data.Migrations +{ + [DbContext(typeof(LudosDbContext))] + [Migration("20260804171435_AddCollectorFields")] + partial class AddCollectorFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("LudosData.Api.Domain.AppUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("Art") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("FirstName") + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("LudosData.Api.Domain.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Art") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Condition") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Developer") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Dumped") + .HasColumnType("INTEGER"); + + b.Property("Finished") + .HasColumnType("INTEGER"); + + b.Property("Genre") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MarketValue") + .HasColumnType("INTEGER"); + + b.Property("MarketValueSource") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MarketValueUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Own") + .HasColumnType("INTEGER"); + + b.Property("OwnerId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Publisher") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PurchaseDate") + .HasColumnType("TEXT"); + + b.Property("PurchasePrice") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Region") + .HasColumnType("INTEGER"); + + b.Property("System") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId", "Genre"); + + b.HasIndex("OwnerId", "Rating"); + + b.HasIndex("OwnerId", "System"); + + b.HasIndex("OwnerId", "Title"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("LudosData.Api.Domain.Game", b => + { + b.HasOne("LudosData.Api.Domain.AppUser", "Owner") + .WithMany("Games") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("LudosData.Api.Domain.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("LudosData.Api.Domain.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LudosData.Api.Domain.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("LudosData.Api.Domain.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LudosData.Api.Domain.AppUser", b => + { + b.Navigation("Games"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/LudosData.Api/Data/Migrations/20260804171435_AddCollectorFields.cs b/backend/src/LudosData.Api/Data/Migrations/20260804171435_AddCollectorFields.cs new file mode 100644 index 0000000..8efd501 --- /dev/null +++ b/backend/src/LudosData.Api/Data/Migrations/20260804171435_AddCollectorFields.cs @@ -0,0 +1,121 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LudosData.Api.Data.Migrations +{ + /// + public partial class AddCollectorFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Condition", + table: "Games", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "MarketValue", + table: "Games", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "MarketValueSource", + table: "Games", + type: "TEXT", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "MarketValueUpdatedAt", + table: "Games", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Notes", + table: "Games", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PurchaseDate", + table: "Games", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PurchasePrice", + table: "Games", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "Rating", + table: "Games", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "Region", + table: "Games", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateIndex( + name: "IX_Games_OwnerId_Rating", + table: "Games", + columns: new[] { "OwnerId", "Rating" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Games_OwnerId_Rating", + table: "Games"); + + migrationBuilder.DropColumn( + name: "Condition", + table: "Games"); + + migrationBuilder.DropColumn( + name: "MarketValue", + table: "Games"); + + migrationBuilder.DropColumn( + name: "MarketValueSource", + table: "Games"); + + migrationBuilder.DropColumn( + name: "MarketValueUpdatedAt", + table: "Games"); + + migrationBuilder.DropColumn( + name: "Notes", + table: "Games"); + + migrationBuilder.DropColumn( + name: "PurchaseDate", + table: "Games"); + + migrationBuilder.DropColumn( + name: "PurchasePrice", + table: "Games"); + + migrationBuilder.DropColumn( + name: "Rating", + table: "Games"); + + migrationBuilder.DropColumn( + name: "Region", + table: "Games"); + } + } +} diff --git a/backend/src/LudosData.Api/Data/Migrations/LudosDbContextModelSnapshot.cs b/backend/src/LudosData.Api/Data/Migrations/LudosDbContextModelSnapshot.cs index 584305e..8eb1bd0 100644 --- a/backend/src/LudosData.Api/Data/Migrations/LudosDbContextModelSnapshot.cs +++ b/backend/src/LudosData.Api/Data/Migrations/LudosDbContextModelSnapshot.cs @@ -103,6 +103,9 @@ namespace LudosData.Api.Data.Migrations .HasMaxLength(200) .HasColumnType("TEXT"); + b.Property("Condition") + .HasColumnType("INTEGER"); + b.Property("CreatedAt") .HasColumnType("TEXT"); @@ -123,6 +126,19 @@ namespace LudosData.Api.Data.Migrations .HasMaxLength(50) .HasColumnType("TEXT"); + b.Property("MarketValue") + .HasColumnType("INTEGER"); + + b.Property("MarketValueSource") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MarketValueUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + b.Property("Own") .HasColumnType("INTEGER"); @@ -137,6 +153,18 @@ namespace LudosData.Api.Data.Migrations .HasMaxLength(100) .HasColumnType("TEXT"); + b.Property("PurchaseDate") + .HasColumnType("TEXT"); + + b.Property("PurchasePrice") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Region") + .HasColumnType("INTEGER"); + b.Property("System") .HasMaxLength(50) .HasColumnType("TEXT"); @@ -157,6 +185,8 @@ namespace LudosData.Api.Data.Migrations b.HasIndex("OwnerId", "Genre"); + b.HasIndex("OwnerId", "Rating"); + b.HasIndex("OwnerId", "System"); b.HasIndex("OwnerId", "Title"); diff --git a/backend/src/LudosData.Api/Domain/Enums.cs b/backend/src/LudosData.Api/Domain/Enums.cs new file mode 100644 index 0000000..77902b1 --- /dev/null +++ b/backend/src/LudosData.Api/Domain/Enums.cs @@ -0,0 +1,34 @@ +namespace LudosData.Api.Domain; + +/// +/// Physical completeness. This is not cosmetic: market price feeds quote per +/// condition, and the gap between loose and sealed is routinely a multiple, so +/// this selects which quoted price applies to a copy. +/// +public enum GameCondition +{ + Unspecified = 0, + + /// Cartridge or disc only. + Loose = 1, + + /// Complete in box — case, manual and inserts present. + Cib = 2, + + /// Factory sealed, never opened. + Sealed = 3, + + /// No physical copy; a download or licence. + Digital = 4, +} + +/// +/// Release region. Affects both value and playability on a given console. +/// +public enum GameRegion +{ + Unspecified = 0, + Ntsc = 1, // North America + Pal = 2, // Europe / Australia + NtscJ = 3, // Japan +} diff --git a/backend/src/LudosData.Api/Domain/Game.cs b/backend/src/LudosData.Api/Domain/Game.cs index 2ce6467..0613c11 100644 --- a/backend/src/LudosData.Api/Domain/Game.cs +++ b/backend/src/LudosData.Api/Domain/Game.cs @@ -47,6 +47,41 @@ public class Game public bool Played { get; set; } public bool Finished { get; set; } + // ---- collector fields ------------------------------------------------ + + /// Personal score out of 10. Null means unrated, which is not zero. + [Range(1, 10)] + public int? Rating { get; set; } + + /// + /// Free-form personal notes. Kept separate from Description, which is + /// derived from an external source and may be overwritten by the enricher. + /// + public string? Notes { get; set; } + + public GameCondition Condition { get; set; } = GameCondition.Unspecified; + public GameRegion Region { get; set; } = GameRegion.Unspecified; + + /// What was paid for this copy. A fixed historical fact. + public decimal? PurchasePrice { get; set; } + + public DateOnly? PurchaseDate { get; set; } + + // ---- market value ---------------------------------------------------- + // + // Distinct from PurchasePrice: an estimate of what a copy sells for now, + // expected to be refreshed from a price feed. Stored with the moment it was + // captured and where it came from, because a figure with neither is not + // something you can reason about — a total is only as good as its staleness. + + public decimal? MarketValue { get; set; } + + public DateTimeOffset? MarketValueUpdatedAt { get; set; } + + /// Provenance, e.g. a price feed's name, or "manual". + [MaxLength(100)] + public string? MarketValueSource { get; set; } + /// /// Owning user. Every query is filtered on this server-side, from the JWT subject — /// it is never accepted from the client. diff --git a/backend/src/LudosData.Api/Program.cs b/backend/src/LudosData.Api/Program.cs index 1bbbc0f..075841e 100644 --- a/backend/src/LudosData.Api/Program.cs +++ b/backend/src/LudosData.Api/Program.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Text.Json.Serialization; using LudosData.Api.Auth; using LudosData.Api.Data; using LudosData.Api.Domain; @@ -112,7 +113,16 @@ builder.Services.AddAuthorization(); builder.Services.AddScoped(); builder.Services.AddSingleton(); -builder.Services.AddControllers(); +builder.Services + .AddControllers() + .AddJsonOptions(options => + { + // Enums travel as names, not ordinals. "Cib" is self-describing in a + // payload, an export file and a log line; 2 is not, and renumbering the + // enum would silently reinterpret every stored export. + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); + builder.Services.AddProblemDetails(); builder.Services.AddOpenApi(); diff --git a/backend/tests/LudosData.Api.Tests/AuthTests.cs b/backend/tests/LudosData.Api.Tests/AuthTests.cs index 3605802..42dd395 100644 --- a/backend/tests/LudosData.Api.Tests/AuthTests.cs +++ b/backend/tests/LudosData.Api.Tests/AuthTests.cs @@ -17,10 +17,10 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture { var client = factory.CreateClient(); - var response = await client.PostAsJsonAsync("/api/auth/register", Registration("reg-ok")); + var response = await client.PostJsonAsync("/api/auth/register", Registration("reg-ok")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var auth = await response.Content.ReadFromJsonAsync(); + var auth = await response.Content.ReadJsonAsync(); Assert.False(string.IsNullOrWhiteSpace(auth!.Token)); Assert.Equal("reg-ok", auth.User.UserName); Assert.True(auth.ExpiresAt > DateTimeOffset.UtcNow); @@ -35,7 +35,7 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture { var client = factory.CreateClient(); - var response = await client.PostAsJsonAsync( + var response = await client.PostJsonAsync( "/api/auth/register", Registration($"weak-{password.Length}-{password[0]}", password)); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); @@ -45,9 +45,9 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture public async Task Register_rejects_a_duplicate_username() { var client = factory.CreateClient(); - await client.PostAsJsonAsync("/api/auth/register", Registration("dupe-user")); + await client.PostJsonAsync("/api/auth/register", Registration("dupe-user")); - var second = await client.PostAsJsonAsync("/api/auth/register", Registration("dupe-user")); + var second = await client.PostJsonAsync("/api/auth/register", Registration("dupe-user")); Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); } @@ -56,9 +56,9 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture public async Task Login_succeeds_with_the_right_password() { var client = factory.CreateClient(); - await client.PostAsJsonAsync("/api/auth/register", Registration("login-ok")); + await client.PostJsonAsync("/api/auth/register", Registration("login-ok")); - var response = await client.PostAsJsonAsync( + var response = await client.PostJsonAsync( "/api/auth/login", new { userName = "login-ok", password = "TestPassword123" }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -68,9 +68,9 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture public async Task Login_rejects_a_wrong_password() { var client = factory.CreateClient(); - await client.PostAsJsonAsync("/api/auth/register", Registration("login-bad")); + await client.PostJsonAsync("/api/auth/register", Registration("login-bad")); - var response = await client.PostAsJsonAsync( + var response = await client.PostJsonAsync( "/api/auth/login", new { userName = "login-bad", password = "WrongPassword123" }); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); @@ -80,11 +80,11 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture public async Task Login_does_not_reveal_whether_a_username_exists() { var client = factory.CreateClient(); - await client.PostAsJsonAsync("/api/auth/register", Registration("enum-real")); + await client.PostJsonAsync("/api/auth/register", Registration("enum-real")); - var wrongPassword = await client.PostAsJsonAsync( + var wrongPassword = await client.PostJsonAsync( "/api/auth/login", new { userName = "enum-real", password = "WrongPassword123" }); - var noSuchUser = await client.PostAsJsonAsync( + var noSuchUser = await client.PostJsonAsync( "/api/auth/login", new { userName = "enum-absent", password = "WrongPassword123" }); // Identical status and body, so the endpoint cannot be used to harvest @@ -99,11 +99,11 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture public async Task Availability_reports_taken_and_free_names_without_leaking_the_row() { var client = factory.CreateClient(); - await client.PostAsJsonAsync("/api/auth/register", Registration("taken-name")); + await client.PostJsonAsync("/api/auth/register", Registration("taken-name")); - var taken = await client.GetFromJsonAsync( + var taken = await client.GetJsonAsync( "/api/auth/available?userName=taken-name"); - var free = await client.GetFromJsonAsync( + var free = await client.GetJsonAsync( "/api/auth/available?userName=definitely-free-name"); Assert.False(taken!.Available); @@ -131,7 +131,7 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture { var client = await factory.CreateUserClientAsync("me-user"); - var user = await client.GetFromJsonAsync("/api/auth/me"); + var user = await client.GetJsonAsync("/api/auth/me"); Assert.Equal("me-user", user!.UserName); } diff --git a/backend/tests/LudosData.Api.Tests/CollectorFieldTests.cs b/backend/tests/LudosData.Api.Tests/CollectorFieldTests.cs new file mode 100644 index 0000000..abe8528 --- /dev/null +++ b/backend/tests/LudosData.Api.Tests/CollectorFieldTests.cs @@ -0,0 +1,325 @@ +using System.Net; +using System.Net.Http.Json; +using LudosData.Api.Contracts; +using LudosData.Api.Domain; + +namespace LudosData.Api.Tests; + +public class CollectorFieldTests(LudosApiFactory factory) : IClassFixture +{ + private static object Game( + string title, + string system = "SNES", + int? rating = null, + string? notes = null, + GameCondition condition = GameCondition.Unspecified, + GameRegion region = GameRegion.Unspecified, + decimal? purchasePrice = null, + string? purchaseDate = null, + decimal? marketValue = null, + string? marketValueSource = null) => new + { + title, system, own = true, + rating, notes, condition, region, + purchasePrice, purchaseDate, marketValue, marketValueSource, + }; + + private static async Task CreateAsync(HttpClient client, object body) + { + var response = await client.PostJsonAsync("/api/games", body); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadJsonAsync())!; + } + + [Fact] + public async Task Collector_fields_round_trip() + { + var client = await factory.CreateUserClientAsync("cf-roundtrip"); + + var created = await CreateAsync(client, Game( + "Panzer Dragoon Saga", + rating: 9, + notes: "Bought at a swap meet. Disc 2 has a scratch.", + condition: GameCondition.Cib, + region: GameRegion.Ntsc, + purchasePrice: 249.99m, + purchaseDate: "2019-06-14", + marketValue: 1150.00m, + marketValueSource: "pricecharting")); + + Assert.Equal(9, created.Rating); + Assert.Equal("Bought at a swap meet. Disc 2 has a scratch.", created.Notes); + Assert.Equal(GameCondition.Cib, created.Condition); + Assert.Equal(GameRegion.Ntsc, created.Region); + Assert.Equal(249.99m, created.PurchasePrice); + Assert.Equal(new DateOnly(2019, 6, 14), created.PurchaseDate); + Assert.Equal(1150.00m, created.MarketValue); + Assert.Equal("pricecharting", created.MarketValueSource); + } + + [Fact] + public async Task Money_keeps_its_cents_through_storage() + { + var client = await factory.CreateUserClientAsync("cf-cents"); + + // Money is stored as integer minor units, so the awkward values are the + // ones worth checking. + var created = await CreateAsync(client, Game("Cent Test", + purchasePrice: 0.01m, marketValue: 19.99m)); + + Assert.Equal(0.01m, created.PurchasePrice); + Assert.Equal(19.99m, created.MarketValue); + + var reloaded = await client.GetJsonAsync($"/api/games/{created.Id}"); + Assert.Equal(0.01m, reloaded!.PurchasePrice); + Assert.Equal(19.99m, reloaded.MarketValue); + } + + [Fact] + public async Task Sorting_by_value_is_numeric_not_lexical() + { + var client = await factory.CreateUserClientAsync("cf-sort"); + await CreateAsync(client, Game("Nine", marketValue: 9m)); + await CreateAsync(client, Game("Ten", marketValue: 10m)); + await CreateAsync(client, Game("Hundred", marketValue: 100m)); + + var page = await client.GetJsonAsync("/api/games?sort=value&dir=desc"); + + // Stored as text, "9" would sort above "100" and this would read + // Nine, Ten, Hundred. + Assert.Equal(["Hundred", "Ten", "Nine"], page!.Items.Select(g => g.Title)); + } + + [Fact] + public async Task Rating_must_be_between_1_and_10() + { + var client = await factory.CreateUserClientAsync("cf-rating"); + + Assert.Equal(HttpStatusCode.BadRequest, + (await client.PostJsonAsync("/api/games", Game("Too low", rating: 0))).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, + (await client.PostJsonAsync("/api/games", Game("Too high", rating: 11))).StatusCode); + + // Null is unrated, which is legitimate and not the same as zero. + var unrated = await CreateAsync(client, Game("Unrated")); + Assert.Null(unrated.Rating); + } + + [Fact] + public async Task A_minimum_rating_filter_excludes_unrated_games() + { + var client = await factory.CreateUserClientAsync("cf-minrating"); + await CreateAsync(client, Game("Great", rating: 9)); + await CreateAsync(client, Game("Fine", rating: 6)); + await CreateAsync(client, Game("Unrated")); + + var page = await client.GetJsonAsync("/api/games?minRating=7"); + + Assert.Equal("Great", Assert.Single(page!.Items).Title); + } + + [Fact] + public async Task Condition_and_region_filter() + { + var client = await factory.CreateUserClientAsync("cf-filters"); + await CreateAsync(client, Game("Sealed Copy", condition: GameCondition.Sealed, region: GameRegion.Ntsc)); + await CreateAsync(client, Game("Loose Copy", condition: GameCondition.Loose, region: GameRegion.Pal)); + + var sealedOnly = await client.GetJsonAsync("/api/games?condition=Sealed"); + var palOnly = await client.GetJsonAsync("/api/games?region=Pal"); + + Assert.Equal("Sealed Copy", Assert.Single(sealedOnly!.Items).Title); + Assert.Equal("Loose Copy", Assert.Single(palOnly!.Items).Title); + } + + [Fact] + public async Task HasValue_separates_valued_from_unvalued_games() + { + var client = await factory.CreateUserClientAsync("cf-hasvalue"); + await CreateAsync(client, Game("Valued", marketValue: 40m)); + await CreateAsync(client, Game("Unvalued")); + + var valued = await client.GetJsonAsync("/api/games?hasValue=true"); + var unvalued = await client.GetJsonAsync("/api/games?hasValue=false"); + + Assert.Equal("Valued", Assert.Single(valued!.Items).Title); + Assert.Equal("Unvalued", Assert.Single(unvalued!.Items).Title); + } + + [Fact] + public async Task Setting_a_value_stamps_when_and_where_it_came_from() + { + var client = await factory.CreateUserClientAsync("cf-stamp"); + var before = DateTimeOffset.UtcNow.AddSeconds(-1); + + var created = await CreateAsync(client, Game("Stamped", marketValue: 55m)); + + Assert.NotNull(created.MarketValueUpdatedAt); + Assert.True(created.MarketValueUpdatedAt >= before); + // No source given, so it is recorded as hand-entered. + Assert.Equal("manual", created.MarketValueSource); + } + + [Fact] + public async Task An_unrelated_edit_does_not_make_a_stale_valuation_look_fresh() + { + var client = await factory.CreateUserClientAsync("cf-nostamp"); + var created = await CreateAsync(client, Game("Keeps Its Date", marketValue: 30m)); + var originalStamp = created.MarketValueUpdatedAt; + + await Task.Delay(20); + // Change the notes, leave the value alone. + var updated = await (await client.PutJsonAsync($"/api/games/{created.Id}", + Game("Keeps Its Date", notes: "Edited something else", marketValue: 30m))) + .Content.ReadJsonAsync(); + + Assert.Equal("Edited something else", updated!.Notes); + Assert.Equal(originalStamp, updated.MarketValueUpdatedAt); + } + + [Fact] + public async Task Changing_the_value_moves_the_timestamp() + { + var client = await factory.CreateUserClientAsync("cf-restamp"); + var created = await CreateAsync(client, Game("Repriced", marketValue: 30m)); + + await Task.Delay(20); + var updated = await (await client.PutJsonAsync($"/api/games/{created.Id}", + Game("Repriced", marketValue: 45m))).Content.ReadJsonAsync(); + + Assert.Equal(45m, updated!.MarketValue); + Assert.True(updated.MarketValueUpdatedAt > created.MarketValueUpdatedAt); + } + + [Fact] + public async Task Clearing_the_value_clears_its_metadata_too() + { + var client = await factory.CreateUserClientAsync("cf-clear"); + var created = await CreateAsync(client, Game("Devalued", marketValue: 30m, marketValueSource: "feed")); + + var updated = await (await client.PutJsonAsync($"/api/games/{created.Id}", + Game("Devalued"))).Content.ReadJsonAsync(); + + Assert.Null(updated!.MarketValue); + Assert.Null(updated.MarketValueUpdatedAt); + Assert.Null(updated.MarketValueSource); + } + + [Fact] + public async Task Negative_money_is_rejected() + { + var client = await factory.CreateUserClientAsync("cf-negative"); + + Assert.Equal(HttpStatusCode.BadRequest, + (await client.PostJsonAsync("/api/games", Game("Negative", purchasePrice: -5m))).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, + (await client.PostJsonAsync("/api/games", Game("Negative", marketValue: -5m))).StatusCode); + } + + // ---- export / import ------------------------------------------------- + + [Fact] + public async Task Collector_fields_survive_a_json_round_trip() + { + var source = await factory.CreateUserClientAsync("cf-json-src"); + await CreateAsync(source, Game("Full House", + rating: 8, notes: "note", condition: GameCondition.Cib, region: GameRegion.NtscJ, + purchasePrice: 12.34m, purchaseDate: "2020-01-02", + marketValue: 56.78m, marketValueSource: "feed")); + + var exported = await source.GetStringAsync("/api/library/export?format=json"); + + var target = await factory.CreateUserClientAsync("cf-json-dst"); + await target.PostAsync("/api/library/import", FileContent(exported, "l.json")); + + var game = Assert.Single((await target.GetJsonAsync( + "/api/library/export?format=json"))!.Games); + + Assert.Equal(8, game.Rating); + Assert.Equal(GameCondition.Cib, game.Condition); + Assert.Equal(GameRegion.NtscJ, game.Region); + Assert.Equal(12.34m, game.PurchasePrice); + Assert.Equal(new DateOnly(2020, 1, 2), game.PurchaseDate); + Assert.Equal(56.78m, game.MarketValue); + Assert.Equal("feed", game.MarketValueSource); + } + + [Fact] + public async Task Collector_fields_survive_a_csv_round_trip() + { + var source = await factory.CreateUserClientAsync("cf-csv-src"); + await CreateAsync(source, Game("CSV House", + rating: 7, condition: GameCondition.Sealed, region: GameRegion.Pal, + purchasePrice: 99.95m, purchaseDate: "2021-11-30", marketValue: 250m)); + + var csv = await source.GetStringAsync("/api/library/export?format=csv"); + + var target = await factory.CreateUserClientAsync("cf-csv-dst"); + await target.PostAsync("/api/library/import", FileContent(csv, "l.csv")); + + var game = Assert.Single((await target.GetJsonAsync( + "/api/library/export?format=json"))!.Games); + + Assert.Equal(7, game.Rating); + Assert.Equal(GameCondition.Sealed, game.Condition); + Assert.Equal(GameRegion.Pal, game.Region); + Assert.Equal(99.95m, game.PurchasePrice); + Assert.Equal(250m, game.MarketValue); + } + + [Fact] + public async Task Importing_restores_a_valuation_date_rather_than_resetting_it() + { + var client = await factory.CreateUserClientAsync("cf-import-date"); + + // A valuation captured well in the past should still read as old after a + // restore — an import is not a fresh price check. + var json = """ + [{ "title": "Old Valuation", "system": "PS1", "own": true, + "marketValue": 42.00, "marketValueUpdatedAt": "2020-03-01T00:00:00+00:00", + "marketValueSource": "archive" }] + """; + + await client.PostAsync("/api/library/import", FileContent(json, "l.json")); + + var game = Assert.Single((await client.GetJsonAsync( + "/api/library/export?format=json"))!.Games); + + Assert.Equal(2020, game.MarketValueUpdatedAt!.Value.Year); + Assert.Equal("archive", game.MarketValueSource); + } + + [Fact] + public async Task Spreadsheet_style_money_is_accepted_on_import() + { + var client = await factory.CreateUserClientAsync("cf-messy-money"); + + // What a spreadsheet actually emits after someone formats a column. + const string csv = """ + title,system,own,purchasePrice,marketValue + Formatted,PS2,true,"$1,234.56","$2,000.00" + """; + + await client.PostAsync("/api/library/import", FileContent(csv, "l.csv")); + + var game = Assert.Single((await client.GetJsonAsync( + "/api/library/export?format=json"))!.Games); + + Assert.Equal(1234.56m, game.PurchasePrice); + Assert.Equal(2000m, game.MarketValue); + } + + private static MultipartFormDataContent FileContent(string body, string name) + { + var content = new MultipartFormDataContent(); + content.Add(new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes(body)), "file", name); + return content; + } + + private record GamePayload( + int Id, string Title, int? Rating, string? Notes, + GameCondition Condition, GameRegion Region, + decimal? PurchasePrice, DateOnly? PurchaseDate, + decimal? MarketValue, DateTimeOffset? MarketValueUpdatedAt, string? MarketValueSource); + private record PagePayload(List Items, int Total); +} diff --git a/backend/tests/LudosData.Api.Tests/GamesTests.cs b/backend/tests/LudosData.Api.Tests/GamesTests.cs index 280d9f9..4770889 100644 --- a/backend/tests/LudosData.Api.Tests/GamesTests.cs +++ b/backend/tests/LudosData.Api.Tests/GamesTests.cs @@ -16,11 +16,11 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture("/api/games?page=1&pageSize=2"); + var page = await client.GetJsonAsync("/api/games?page=1&pageSize=2"); Assert.Equal(2, page!.Items.Count); Assert.Equal(5, page.Total); @@ -42,9 +42,9 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture("/api/games?search=metroid"); - var byDeveloper = await client.GetFromJsonAsync("/api/games?search=Square"); - var byPublisher = await client.GetFromJsonAsync("/api/games?search=Nintendo"); + var byTitle = await client.GetJsonAsync("/api/games?search=metroid"); + var byDeveloper = await client.GetJsonAsync("/api/games?search=Square"); + var byPublisher = await client.GetJsonAsync("/api/games?search=Nintendo"); Assert.Equal("Super Metroid", Assert.Single(byTitle!.Items).Title); Assert.Equal("Chrono Trigger", Assert.Single(byDeveloper!.Items).Title); @@ -57,10 +57,10 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture("/api/games?system=N64"); - var adventure = await client.GetFromJsonAsync("/api/games?genre=adventure"); - var finished = await client.GetFromJsonAsync("/api/games?finished=true"); - var unplayed = await client.GetFromJsonAsync("/api/games?played=false"); + var n64 = await client.GetJsonAsync("/api/games?system=N64"); + var adventure = await client.GetJsonAsync("/api/games?genre=adventure"); + var finished = await client.GetJsonAsync("/api/games?finished=true"); + var unplayed = await client.GetJsonAsync("/api/games?played=false"); Assert.Equal(2, n64!.Total); Assert.Equal(2, adventure!.Total); @@ -74,8 +74,8 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture("/api/games?sort=title&dir=asc"); - var descending = await client.GetFromJsonAsync("/api/games?sort=title&dir=desc"); + var ascending = await client.GetJsonAsync("/api/games?sort=title&dir=asc"); + var descending = await client.GetJsonAsync("/api/games?sort=title&dir=desc"); Assert.Equal("Banjo-Kazooie", ascending!.Items.First().Title); Assert.Equal("Super Metroid", descending!.Items.First().Title); @@ -92,11 +92,11 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture(); + var page = await response.Content.ReadJsonAsync(); Assert.Equal("Banjo-Kazooie", page!.Items.First().Title); // And the table is still there. - var after = await client.GetFromJsonAsync("/api/games"); + var after = await client.GetJsonAsync("/api/games"); Assert.Equal(5, after!.Total); } @@ -118,8 +118,8 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture(); + var created = await (await client.PostJsonAsync("/api/games", Game("Before"))) + .Content.ReadJsonAsync(); await Task.Delay(15); // the stamp has sub-second resolution, but not zero - var updated = await (await client.PutAsJsonAsync( + var updated = await (await client.PutJsonAsync( $"/api/games/{created!.Id}", Game("After", finished: true))) - .Content.ReadFromJsonAsync(); + .Content.ReadJsonAsync(); Assert.Equal("After", updated!.Title); Assert.True(updated.Finished); @@ -147,8 +147,8 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture(); + var created = await (await client.PostJsonAsync("/api/games", Game("Doomed"))) + .Content.ReadJsonAsync(); var response = await client.DeleteAsync($"/api/games/{created!.Id}"); @@ -161,13 +161,13 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture(); + })).Content.ReadJsonAsync(); Assert.Equal("Trimmed", created!.Title); Assert.Null(created.System); @@ -188,7 +188,7 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture(); + var upload = await response.Content.ReadJsonAsync(); Assert.EndsWith(".webp", upload!.FileName); // The stored name is generated server-side, never taken from the upload. @@ -240,9 +240,9 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture(); + .Content.ReadJsonAsync(); - var bobUser = await bob.GetFromJsonAsync("/api/auth/me"); + var bobUser = await bob.GetJsonAsync("/api/auth/me"); var file = upload!.Url.Split('/').Last(); var probe = await bob.GetAsync($"/uploads/{bobUser!.Id}/{file}"); diff --git a/backend/tests/LudosData.Api.Tests/HttpJson.cs b/backend/tests/LudosData.Api.Tests/HttpJson.cs new file mode 100644 index 0000000..99fde2c --- /dev/null +++ b/backend/tests/LudosData.Api.Tests/HttpJson.cs @@ -0,0 +1,33 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace LudosData.Api.Tests; + +/// +/// JSON helpers configured exactly like the API's own serializer. +/// +/// Without this the tests would speak a different dialect from the browser: +/// System.Text.Json writes enums as ordinals by default, so a test could pass +/// while the real client's "condition": "Cib" was rejected with a 400 — +/// which is precisely what happened before the API adopted string enums. +/// +internal static class HttpJson +{ + public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() }, + }; + + public static Task PostJsonAsync(this HttpClient client, string url, T value) + => client.PostAsJsonAsync(url, value, Options); + + public static Task PutJsonAsync(this HttpClient client, string url, T value) + => client.PutAsJsonAsync(url, value, Options); + + public static Task GetJsonAsync(this HttpClient client, string url) + => client.GetFromJsonAsync(url, Options); + + public static Task ReadJsonAsync(this HttpContent content) + => content.ReadFromJsonAsync(Options); +} diff --git a/backend/tests/LudosData.Api.Tests/LibraryTests.cs b/backend/tests/LudosData.Api.Tests/LibraryTests.cs index 90c707b..da82982 100644 --- a/backend/tests/LudosData.Api.Tests/LibraryTests.cs +++ b/backend/tests/LudosData.Api.Tests/LibraryTests.cs @@ -9,7 +9,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture(); + var payload = await response.Content.ReadJsonAsync(); Assert.Equal(2, payload!.Count); Assert.Contains(payload.Games, g => g.Title == "Chrono Trigger" && g.Developer == "Square"); } @@ -79,9 +79,9 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture("/api/library/export?format=json"); + var payload = await bob.GetJsonAsync("/api/library/export?format=json"); Assert.Equal(1, payload!.Count); Assert.Equal("Bob Only", payload.Games[0].Title); @@ -109,8 +109,8 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture("/api/library/export?format=json"); - var after = await target.GetFromJsonAsync("/api/library/export?format=json"); + var before = await source.GetJsonAsync("/api/library/export?format=json"); + var after = await target.GetJsonAsync("/api/library/export?format=json"); Assert.Equal(before!.Count, after!.Count); Assert.Equal( @@ -133,14 +133,14 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture(); + FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync(); Assert.Equal(1, result!.Created); // Super Metroid Assert.Equal(1, result.Updated); // Chrono Trigger matched on title + system Assert.Equal(0, result.Deleted); // The update took effect: it was finished before, and the file says otherwise. - var page = await client.GetFromJsonAsync("/api/games?search=Chrono"); + var page = await client.GetJsonAsync("/api/games?search=Chrono"); Assert.False(page!.Items[0].Finished); } @@ -148,7 +148,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture(); + FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync(); Assert.Equal(2, result!.Created); // GB and GBA Assert.Equal(1, result.Updated); // the existing SNES row @@ -179,12 +179,12 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture(); + FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync(); Assert.True(result!.DryRun); Assert.Equal(1, result.Created); - var page = await client.GetFromJsonAsync("/api/games"); + var page = await client.GetJsonAsync("/api/games"); Assert.Equal(2, page!.Total); // still just the seeded pair } @@ -200,12 +200,12 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture(); + FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync(); Assert.Equal(2, result!.Deleted); Assert.Equal(1, result.Created); - var page = await client.GetFromJsonAsync("/api/games"); + var page = await client.GetJsonAsync("/api/games"); Assert.Equal(1, page!.Total); Assert.Equal("Only Survivor", page.Items[0].Title); } @@ -222,7 +222,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture("/api/games"); + var alicePage = await alice.GetJsonAsync("/api/games"); Assert.Equal(2, alicePage!.Total); } @@ -238,7 +238,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture(); + FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync(); Assert.Equal(1, result!.Created); Assert.Single(result.Errors); @@ -251,7 +251,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture("/api/library/export?format=json"); + var payload = await target.GetJsonAsync("/api/library/export?format=json"); var game = Assert.Single(payload!.Games); Assert.Equal("Awkward, Game \"Title\"", game.Title); @@ -282,7 +282,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture(); + FileContent(json, "in.json", "application/json"))).Content.ReadJsonAsync(); Assert.Equal(1, result!.Created); } diff --git a/backend/tests/LudosData.Api.Tests/LudosApiFactory.cs b/backend/tests/LudosData.Api.Tests/LudosApiFactory.cs index 9617c53..15cca01 100644 --- a/backend/tests/LudosData.Api.Tests/LudosApiFactory.cs +++ b/backend/tests/LudosData.Api.Tests/LudosApiFactory.cs @@ -41,7 +41,7 @@ public class LudosApiFactory : WebApplicationFactory, IAsyncLifetime public async Task CreateUserClientAsync(string userName) { var client = CreateClient(); - var response = await client.PostAsJsonAsync("/api/auth/register", new + var response = await client.PostJsonAsync("/api/auth/register", new { userName, email = $"{userName}@example.test", @@ -49,7 +49,7 @@ public class LudosApiFactory : WebApplicationFactory, IAsyncLifetime }); response.EnsureSuccessStatusCode(); - var auth = await response.Content.ReadFromJsonAsync(); + var auth = await response.Content.ReadJsonAsync(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", auth!.Token); diff --git a/backend/tests/LudosData.Api.Tests/OwnershipTests.cs b/backend/tests/LudosData.Api.Tests/OwnershipTests.cs index e5da53b..a25c120 100644 --- a/backend/tests/LudosData.Api.Tests/OwnershipTests.cs +++ b/backend/tests/LudosData.Api.Tests/OwnershipTests.cs @@ -28,9 +28,9 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture CreateGameAsync(HttpClient client, string title) { - var response = await client.PostAsJsonAsync("/api/games", Game(title)); + var response = await client.PostJsonAsync("/api/games", Game(title)); response.EnsureSuccessStatusCode(); - var created = await response.Content.ReadFromJsonAsync(); + var created = await response.Content.ReadJsonAsync(); return created!.Id; } @@ -43,8 +43,8 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture("/api/games"); - var bobPage = await bob.GetFromJsonAsync("/api/games"); + var alicePage = await alice.GetJsonAsync("/api/games"); + var bobPage = await bob.GetJsonAsync("/api/games"); Assert.Single(alicePage!.Items); Assert.Equal("Alice's Game", alicePage.Items[0].Title); @@ -73,11 +73,11 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture($"/api/games/{aliceGame}"); + var after = await alice.GetJsonAsync($"/api/games/{aliceGame}"); Assert.Equal("Untouched", after!.Title); } @@ -102,11 +102,11 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture("/api/auth/me"); + var bobUser = await bob.GetJsonAsync("/api/auth/me"); // Alice creates a game while claiming it belongs to Bob. The contract has // no ownerId, so this should be ignored rather than honoured. - var response = await alice.PostAsJsonAsync("/api/games", new + var response = await alice.PostJsonAsync("/api/games", new { title = "Attempted Handover", system = "SNES", @@ -116,8 +116,8 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture("/api/games"); - var bobPage = await bob.GetFromJsonAsync("/api/games"); + var alicePage = await alice.GetJsonAsync("/api/games"); + var bobPage = await bob.GetJsonAsync("/api/games"); Assert.Single(alicePage!.Items); Assert.Empty(bobPage!.Items); @@ -129,10 +129,10 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture("/api/games/facets"); + var facets = await alice.GetJsonAsync("/api/games/facets"); Assert.Equal(["N64"], facets!.Systems); Assert.Equal(["fps"], facets.Genres); @@ -147,9 +147,9 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture { @@ -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, }; diff --git a/frontend/src/app/features/game-edit/game-edit.html b/frontend/src/app/features/game-edit/game-edit.html index 380784d..1f8d42b 100644 --- a/frontend/src/app/features/game-edit/game-edit.html +++ b/frontend/src/app/features/game-edit/game-edit.html @@ -116,6 +116,79 @@ Finished +
+ Your copy + +
+ + Rating + + Unrated + @for (score of [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]; track score) { + {{ score }} / 10 + } + + + + + Condition + + @for (option of conditions; track option.value) { + {{ option.label }} + } + + + + + Region + + @for (option of regions; track option.value) { + {{ option.label }} + } + + +
+ + + Notes + + + +
+ + Paid + $  + + + + + Purchased + + + + +
+ +
+ + Market value + $  + + @if (valuedAt(); as at) { + As of {{ at | date: 'mediumDate' }} + } @else { + What a copy sells for now + } + + + + Value source + + +
+
+
Cancel diff --git a/frontend/src/app/features/game-edit/game-edit.scss b/frontend/src/app/features/game-edit/game-edit.scss index 20c650e..63de03c 100644 --- a/frontend/src/app/features/game-edit/game-edit.scss +++ b/frontend/src/app/features/game-edit/game-edit.scss @@ -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); + } +} diff --git a/frontend/src/app/features/game-edit/game-edit.ts b/frontend/src/app/features/game-edit/game-edit.ts index 07e9e93..2f42000 100644 --- a/frontend/src/app/features/game-edit/game-edit.ts +++ b/frontend/src/app/features/game-edit/game-edit.ts @@ -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(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(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}`; +} diff --git a/frontend/src/app/features/game-grid/game-grid.html b/frontend/src/app/features/game-grid/game-grid.html index 203a106..ab8fb5c 100644 --- a/frontend/src/app/features/game-grid/game-grid.html +++ b/frontend/src/app/features/game-grid/game-grid.html @@ -58,6 +58,10 @@ System Genre Year + Rating + Market value + Paid + Purchase date Date added Last updated @@ -108,6 +112,15 @@ videogame_asset
} + + @if (game.rating) { + + star{{ game.rating }} + + } + @if (game.marketValue !== null) { + {{ game.marketValue | currency: 'USD' : 'symbol' : '1.0-0' }} + }
diff --git a/frontend/src/app/features/game-grid/game-grid.scss b/frontend/src/app/features/game-grid/game-grid.scss index 6053b14..27641cc 100644 --- a/frontend/src/app/features/game-grid/game-grid.scss +++ b/frontend/src/app/features/game-grid/game-grid.scss @@ -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; +} diff --git a/frontend/src/app/features/game-grid/game-grid.ts b/frontend/src/app/features/game-grid/game-grid.ts index d6d1073..20d27ce 100644 --- a/frontend/src/app/features/game-grid/game-grid.ts +++ b/frontend/src/app/features/game-grid/game-grid.ts @@ -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', diff --git a/tools/library/__pycache__/fetch_art.cpython-314.pyc b/tools/library/__pycache__/fetch_art.cpython-314.pyc index 879d25254f34185b3abdd98465f82589cf445556..0fb2661f8dfdad72756f208234e9f36cd9f1e5ef 100644 GIT binary patch delta 2738 zcmYjT3v3kE6@71ZcDx^BZ2U{W#$(pCv-W}$z~Scr{$jwa*JHeP62oS^JGLiYdpB=p zF%TL%RjSgct)P!85Gkrb5S%uNOg1VZ{Y8~fP#RH49f77PqH3CcA_A$Yl2VfP-dO`3 z+2@^m-+iCCbKiM8ah-m5mgdyt%<~X<4$17vw|fuf+((lKt3JpIcTlrH>G>f&KE9)* zoIFUB-VwTzVk9AEsZw7|lt+q5Jl9;TJgPjb2+C?js1=m;;NO7$xDcminX(b=20Knx z8qIR0$y}l|n?9w*s8CvqiTMp>6GXJx5tYhj;9G27rECS>4!kATd_dU-M$qo?D;>Z? zHm_DXfh#tzQM!O{w|QX4Ql)!!FCnpogp`mSS)o2Y=`Ij<1o%#&ls=Z;C%jwA=XvYA z;-EUBSz=6&ntYqLa^oBDb_NV06XXjb()`a64maPxX1c<*m>;^MbD022hy?#$LY%XQ z2tQx6q&b*C;Cqn4zE)&fGt1VWfEEc8K}dDg)2K?m+_>jFZ0eEq`KB0}6`F-eL^ zA?XN<{OH4l<98EYM#7>GW-B8pN79iLbmFIbTN>nnR6M>k(f_0tHRVwEcDX+h+anJo zn5@e9VgWhHG(%&%^wB|iK-c0iLmt!QSVA66n6jBjMTeI89qe1MDJ#FLMG3bkk&d2V zOLL@kb4z>kc$X!mk}=iP{7&`)2DuZvG{*FpW>|EX6{0IEsqTp<)R^G}t1=+VGOI#= zo|%Yb21n*1*pI<)C%6H~1i30#ob(*^9M7J~*>*Jhh38JQR5}U~>=^&%lC{p)fu7;NSaNC6F?0q2EU`bH=x6)Tu0S}S2Ys?) z)9 zTLOQk^j+>>COh8;|7{*z_P!v|bV==P2R+U2U(r@{cNNRcG&L46wcRE=%g0u%E_(%r z_+4xbH0(TpB^v60W;vs3bVy^<{QQbl(hcy#gx-};( zWQkOgwV|U4;kO9*?(7qUzasn%z@L}d74ARtCbP(Ha%){X{ft-DZ*+=q)=JXx`h3BA z6Z?D!=lS#NH&7-0-uh=9bcVM#Hq$@xgN>)?5N~R#U-&V`+)W+(3!38y@9-l{O?7`q z`VWLp5ipUK1c}u~V}3WYSFsc7iK?S99S@MfuJOF)M&}~fjFRqcev#61cD6>+_q9GP zNTuLsi}{hZwe%@I(-ykpsNP)e^g-VU4{g4ZRfJIz<#k)y=vexhE!9q_rB^yy=$rg{ zM|0sh+)O^k;1J6(qV8t5cw0!OMO+X0=-2%Fp$b~V-v|ZiMNT^d&PIqEOE2$q2_>h1 z8X!Wp^0f(c-sc2ye-zquxa(97-Naq}Vd-bMNE?s!Z=hBDcz=Vm2^VbRpZ0fC7jK9* z(qsI&=;QPzzZ(6v7gZxuGSfU6>mI*~t1O#aMRfL1kW|0}+d*NWoh;0>)4ojm7D_Jo zGn$J>;?9F-KOZ%BH;{T^g7m@6nWszaa`u$36f;)R(RX)TSEs1ZFj0g6O8nfDO_mE? z&OYZIyT_q8{=Z)lR|%Ou#i>ZE=HkkTlI4Tr!#~sl<9ofea5igEa*bL`&|{WsK#yxd zu>I6>sLZq+AkXX>m?sE=i(0^5M?gvROPLx*?a1hoE0sh^)nfPL#lTCZva(X1n8-+i zq?#riRI6Y_DI4uZa%ARp(KO^TD+RPXQe>-W1pa^)Ti)t(-E(Q?=Acdk{L0`W`U$@^Se93bAJ|rjUSL7? zCa)d}P&e-x+9I97J-^LQ4*kayz_19wL+MxbLfV0c#PWc!8CpbTg9hXwsap~Q)c`%X zhP}=t(??A^6PB2Qi;4wtl0t+>ab~-i=cBoZ=MEP-NA%H%5lt}7&KA3cQ)R{2 zo(M~g&K*-6oQvZ+k=d%9$JOD|?WhLqH|T5z@R!@t3oBb9?r}~B)s#5|8ib#deTK2u z5q9F@S;ij2)Ih3TaosTCUNt~$J{VqG_&82%m%E*+uW`g0ULIda|I6#+k9PDOtOCe~qS{#-AhmCBj(*TgYumw=<1;XR8sz zPsc9t;nV}3mx00`F=o>*r6TnHjg%D2|0F`WFiV7D#|4uAFAx6q%Hdz_>XT4~ZiIPc Gto(oJ3*B1) delta 2281 zcmYjSeQ*=U72iGSPLds52V*<6F_w`qh?qFEF>yR#BY&tac8xh%a@v}Tv+slsB>7mK zFeXe)rqdbcpVnlX!ILH+5<*DRWFW^hv_r$RnIv@DhEAGJ+JmN~A_W|#Bc%%F+-~$d{E^h?BY|!bfkT*d`c6ey>N;%s4 zFd=FUA&bdo&tRN?*ttm99O91(b#&NzT6nvTm-V*t8%w+CQR|_=YgF0~n`E>6RTi5rD1920e}eD~zr7;TRzUhB!U2Rs03i?C zh-Q~cAG*7}1mCFErfLBrkzu-!Q&soCO~GM3WV5gu9dp!5mI!Ql7T zJVFDud#jSs*bDReyGwE=^Y8HwDrSlWQTi75w+3mc)zi8~44y^Fy8yPN>joP)>=Iqi zv-E_ng8nNw`X2x7y06F32gCKVPJ~|r*y2_;o;O%83c3(}gMgc7e?#~H;qL&UyV<{x z;|7XbxrFR8-_+huKjsJ8*NeA7P-=bDULllT#<2jxo1BI_XxIveA9K;)@i)4=>0h|F z=QthazwB9Ca}IUBj2%0V-6@3EczL9&(?06aN;8SW` zdKsOp=jYT8TF+~>u+)JL*Yi;=MhQQubwciZp?#NL=EKP!lsbpwT$v;MlVoh_EPACQGiF+@*%oxwb`8dc*rVWhAXHks;ds9wL$oZ@ z7~Xc$Zfnf<^ z5d^G$P!OCW!;<9`FWZ}U)(g|QhHX(zT#YLEp|2JJXgmsMl0-+lq)Zn}iuoBy? zu&ued60YRU6xGB+}9m6$ojQ+M8iTR3Ai zKb@(epK&|W0Qr17(_HcniV+G{OZI!hU3ZfA#zO0$@w&@5>>^cR`uUBdl{BBBA+`#X zLrpA;&Px)BWZuj(EsA(ww=1 int: if args.dry_run: continue - payload = {k: game.get(k) for k in ( - "title", "system", "genre", "year", "developer", "publisher", - "art", "description", "own", "dumped", "played", "finished")} - payload.update(updates) + payload = to_update_payload(game, **updates) try: api_json(base, f"/api/games/{game['id']}", token, data=payload, method="PUT") diff --git a/tools/library/fetch_art.py b/tools/library/fetch_art.py index 363d5e2..8aab684 100644 --- a/tools/library/fetch_art.py +++ b/tools/library/fetch_art.py @@ -165,6 +165,23 @@ def http(url: str, *, data=None, headers=None, method=None, timeout=90) -> bytes raise RuntimeError(f"GET {url} failed after 4 attempts: {last_error}") +# Fields the server derives; everything else on a game round-trips through PUT. +# Listing what to DROP rather than what to KEEP is deliberate: with a keep-list, +# any column added to the model later is silently omitted from the payload and +# therefore nulled on every update. That is exactly how an earlier version of +# these tools erased ratings, notes and valuations across a whole library. +SERVER_OWNED_FIELDS = frozenset({ + "id", "artUrl", "createdAt", "updatedAt", "marketValueUpdatedAt", +}) + + +def to_update_payload(game: dict, **overrides) -> dict: + """A full-object PUT body for a game, preserving fields we do not touch.""" + payload = {k: v for k, v in game.items() if k not in SERVER_OWNED_FIELDS} + payload.update(overrides) + return payload + + def api_json(base: str, path: str, token: str | None = None, *, data=None, method=None): headers = {"Accept": "application/json"} if token: @@ -463,11 +480,8 @@ def main() -> int: def attach(game: dict, blob: bytes, filename: str) -> None: """Upload the image and point the game at it.""" uploaded = upload_image(base, token, filename, blob) - payload = {k: game.get(k) for k in ( - "title", "system", "genre", "year", "developer", "publisher", - "description", "own", "dumped", "played", "finished")} - payload["art"] = uploaded["fileName"] - api_json(base, f"/api/games/{game['id']}", token, data=payload, method="PUT") + api_json(base, f"/api/games/{game['id']}", token, + data=to_update_payload(game, art=uploaded["fileName"]), method="PUT") for game in sorted(games, key=lambda g: g["title"].lower()): title, system = game["title"], game["system"] @@ -483,7 +497,7 @@ def main() -> int: if candidate and score >= args.min_score: flag = " " if score >= 0.95 else "~" - print(f" {flag} {system:4} {title[:46]:48} {score:.2f} {candidate.filename[:50]}") + print(f" {flag} {system or '-':4} {title[:46]:48} {score:.2f} {candidate.filename[:50]}") if score < 0.95: weak.append((game, candidate.filename, score)) @@ -503,7 +517,7 @@ def main() -> int: # --- pass 2: Wikipedia, for anything libretro does not carry -------- if args.no_wikipedia: - print(f" ? {system:4} {title[:46]:48} no match") + print(f" ? {system or '-':4} {title[:46]:48} no match") unmatched.append(game) failed += 1 continue @@ -511,17 +525,17 @@ def main() -> int: try: found = wiki_cover_url(game, cache_dir) except Exception as exc: # noqa: BLE001 - print(f" ! {system:4} {title[:46]:48} wikipedia error: {exc}") + print(f" ! {system or '-':4} {title[:46]:48} wikipedia error: {exc}") found = None if not found: - print(f" ? {system:4} {title[:46]:48} no match") + print(f" ? {system or '-':4} {title[:46]:48} no match") unmatched.append(game) failed += 1 continue url, source = found - print(f" W {system:4} {title[:46]:48} {source[:50]}") + print(f" W {system or '-':4} {title[:46]:48} {source[:50]}") if args.dry_run: applied += 1