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 879d252..0fb2661 100644 Binary files a/tools/library/__pycache__/fetch_art.cpython-314.pyc and b/tools/library/__pycache__/fetch_art.cpython-314.pyc differ diff --git a/tools/library/enrich_metadata.py b/tools/library/enrich_metadata.py index 2151e22..5d50d72 100644 --- a/tools/library/enrich_metadata.py +++ b/tools/library/enrich_metadata.py @@ -29,7 +29,8 @@ from pathlib import Path # The cover fetcher already owns the throttled, cached Wikipedia client. from fetch_art import ( - WIKI_UA, api_json, http, is_game_article, normalise, wiki_api, wiki_article, + WIKI_UA, api_json, http, is_game_article, normalise, to_update_payload, + wiki_api, wiki_article, ) FIELDS = ("developer", "publisher", "year", "description") @@ -400,10 +401,7 @@ def main() -> 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