From 035c232ac4c891971caf775cf472f98ddb1da0d5 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:06:12 +0200 Subject: [PATCH 01/52] feat: add library completion status --- src/GameHours.Core/Domain/LibraryCompletionStatus.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/GameHours.Core/Domain/LibraryCompletionStatus.cs diff --git a/src/GameHours.Core/Domain/LibraryCompletionStatus.cs b/src/GameHours.Core/Domain/LibraryCompletionStatus.cs new file mode 100644 index 0000000..4d27c73 --- /dev/null +++ b/src/GameHours.Core/Domain/LibraryCompletionStatus.cs @@ -0,0 +1,10 @@ +namespace GameHours.Core.Domain; + +public enum LibraryCompletionStatus +{ + Unspecified = 0, + Backlog = 1, + Playing = 2, + Completed = 3, + Abandoned = 4 +} From b9a9382f8f4995b4b4f41d26471875438be77e48 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:06:20 +0200 Subject: [PATCH 02/52] feat: add per-game library preferences --- src/GameHours.Core/Domain/LibraryGamePreferences.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/GameHours.Core/Domain/LibraryGamePreferences.cs diff --git a/src/GameHours.Core/Domain/LibraryGamePreferences.cs b/src/GameHours.Core/Domain/LibraryGamePreferences.cs new file mode 100644 index 0000000..a2fe283 --- /dev/null +++ b/src/GameHours.Core/Domain/LibraryGamePreferences.cs @@ -0,0 +1,13 @@ +namespace GameHours.Core.Domain; + +public sealed record LibraryGamePreferences( + Guid GameId, + bool IsFavorite = false, + bool IsHidden = false, + LibraryCompletionStatus CompletionStatus = LibraryCompletionStatus.Unspecified) +{ + public bool IsDefault => + !IsFavorite && + !IsHidden && + CompletionStatus == LibraryCompletionStatus.Unspecified; +} From 616693a9b97ed8fa6b5fea12d1f1b171b3b84ebc Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:06:46 +0200 Subject: [PATCH 03/52] feat: persist per-game library preferences --- .../SqliteLibraryGamePreferencesRepository.cs | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/GameHours.Storage/Sqlite/SqliteLibraryGamePreferencesRepository.cs diff --git a/src/GameHours.Storage/Sqlite/SqliteLibraryGamePreferencesRepository.cs b/src/GameHours.Storage/Sqlite/SqliteLibraryGamePreferencesRepository.cs new file mode 100644 index 0000000..5098c5b --- /dev/null +++ b/src/GameHours.Storage/Sqlite/SqliteLibraryGamePreferencesRepository.cs @@ -0,0 +1,125 @@ +using GameHours.Core.Domain; + +namespace GameHours.Storage.Sqlite; + +public sealed class SqliteLibraryGamePreferencesRepository +{ + private readonly GameHoursDatabase _database; + + public SqliteLibraryGamePreferencesRepository(GameHoursDatabase database) + { + _database = database ?? throw new ArgumentNullException(nameof(database)); + } + + public async Task> GetAllAsync( + CancellationToken cancellationToken = default) + { + var result = new Dictionary(); + await using var connection = _database.OpenConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT game_id, is_favorite, is_hidden, completion_status + FROM game_library_preferences; + """; + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var gameId = Guid.Parse(reader.GetString(0)); + result[gameId] = new LibraryGamePreferences( + gameId, + reader.GetInt64(1) != 0, + reader.GetInt64(2) != 0, + ReadCompletionStatus(reader.GetInt32(3))); + } + + return result; + } + + public async Task GetAsync( + Guid gameId, + CancellationToken cancellationToken = default) + { + if (gameId == Guid.Empty) + { + throw new ArgumentException("Game id cannot be empty.", nameof(gameId)); + } + + await using var connection = _database.OpenConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT is_favorite, is_hidden, completion_status + FROM game_library_preferences + WHERE game_id = $game_id + LIMIT 1; + """; + command.Parameters.AddWithValue("$game_id", gameId.ToString("D")); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return new LibraryGamePreferences(gameId); + } + + return new LibraryGamePreferences( + gameId, + reader.GetInt64(0) != 0, + reader.GetInt64(1) != 0, + ReadCompletionStatus(reader.GetInt32(2))); + } + + public async Task SetAsync( + LibraryGamePreferences preferences, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(preferences); + if (preferences.GameId == Guid.Empty) + { + throw new ArgumentException("Game id cannot be empty.", nameof(preferences)); + } + + ValidateCompletionStatus(preferences.CompletionStatus); + + await using var connection = _database.OpenConnection(); + await using var command = connection.CreateCommand(); + if (preferences.IsDefault) + { + command.CommandText = "DELETE FROM game_library_preferences WHERE game_id = $game_id;"; + command.Parameters.AddWithValue("$game_id", preferences.GameId.ToString("D")); + await command.ExecuteNonQueryAsync(cancellationToken); + return; + } + + command.CommandText = """ + INSERT INTO game_library_preferences( + game_id, is_favorite, is_hidden, completion_status, updated_at_utc) + VALUES($game_id, $is_favorite, $is_hidden, $completion_status, $updated_at_utc) + ON CONFLICT(game_id) DO UPDATE SET + is_favorite = excluded.is_favorite, + is_hidden = excluded.is_hidden, + completion_status = excluded.completion_status, + updated_at_utc = excluded.updated_at_utc; + """; + command.Parameters.AddWithValue("$game_id", preferences.GameId.ToString("D")); + command.Parameters.AddWithValue("$is_favorite", preferences.IsFavorite ? 1 : 0); + command.Parameters.AddWithValue("$is_hidden", preferences.IsHidden ? 1 : 0); + command.Parameters.AddWithValue("$completion_status", (int)preferences.CompletionStatus); + command.Parameters.AddWithValue("$updated_at_utc", SqliteTime.Serialize(DateTimeOffset.UtcNow)); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private static LibraryCompletionStatus ReadCompletionStatus(int value) + { + var status = (LibraryCompletionStatus)value; + ValidateCompletionStatus(status); + return status; + } + + private static void ValidateCompletionStatus(LibraryCompletionStatus status) + { + if (status is < LibraryCompletionStatus.Unspecified or > LibraryCompletionStatus.Abandoned) + { + throw new InvalidDataException($"Unsupported library completion status: {(int)status}."); + } + } +} From 47b73fb898aa8a02255e8349c668b6c2d4741a08 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:07:41 +0200 Subject: [PATCH 04/52] feat: add library preferences schema v8 --- .../Sqlite/GameHoursDatabase.cs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/GameHours.Storage/Sqlite/GameHoursDatabase.cs b/src/GameHours.Storage/Sqlite/GameHoursDatabase.cs index cf134ff..bfe322f 100644 --- a/src/GameHours.Storage/Sqlite/GameHoursDatabase.cs +++ b/src/GameHours.Storage/Sqlite/GameHoursDatabase.cs @@ -4,7 +4,7 @@ namespace GameHours.Storage.Sqlite; public sealed class GameHoursDatabase { - internal const int CurrentSchemaVersion = 7; + internal const int CurrentSchemaVersion = 8; internal const int ApplicationId = 0x47485253; // "GHRS" private readonly string _connectionString; public string DatabasePath { get; } @@ -112,6 +112,16 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) await SetVersionAsync(connection, transaction, version, cancellationToken); } + // Library preferences are additive and sparse. Re-running CREATE TABLE IF NOT EXISTS also + // repairs a development/restore database whose version marker advanced before the table + // reached disk, without mutating any existing preference rows. + await ExecuteAsync(connection, transaction, MigrationV8, cancellationToken); + if (version < 8) + { + version = 8; + await SetVersionAsync(connection, transaction, version, cancellationToken); + } + await ExecuteAsync(connection, transaction, AchievementCompletionBackfill, cancellationToken); await transaction.CommitAsync(cancellationToken); @@ -319,6 +329,16 @@ PRIMARY KEY (game_id, api_name, provider, rule_id, rule_version, source_path), ); """; + private const string MigrationV8 = """ + CREATE TABLE IF NOT EXISTS game_library_preferences ( + game_id TEXT PRIMARY KEY REFERENCES games(id) ON DELETE CASCADE, + is_favorite INTEGER NOT NULL DEFAULT 0 CHECK (is_favorite IN (0, 1)), + is_hidden INTEGER NOT NULL DEFAULT 0 CHECK (is_hidden IN (0, 1)), + completion_status INTEGER NOT NULL DEFAULT 0 CHECK (completion_status IN (0, 1, 2, 3, 4)), + updated_at_utc TEXT NOT NULL + ); + """; + private const string AchievementCompletionBackfill = """ WITH completed_catalogues AS ( SELECT observation.game_id, From 1732ee5385da080332ffebf8a4d5c53f42841218 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:08:44 +0200 Subject: [PATCH 05/52] feat: add library search and filter toolbar --- src/GameHours.Desktop/LibraryToolbar.xaml | 62 +++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/GameHours.Desktop/LibraryToolbar.xaml diff --git a/src/GameHours.Desktop/LibraryToolbar.xaml b/src/GameHours.Desktop/LibraryToolbar.xaml new file mode 100644 index 0000000..a113f20 --- /dev/null +++ b/src/GameHours.Desktop/LibraryToolbar.xaml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + From 41769f0c0ef778d5e75623973ccd13ce98b767e3 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:08:59 +0200 Subject: [PATCH 06/52] feat: wire library toolbar scopes --- src/GameHours.Desktop/LibraryToolbar.xaml.cs | 60 ++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/GameHours.Desktop/LibraryToolbar.xaml.cs diff --git a/src/GameHours.Desktop/LibraryToolbar.xaml.cs b/src/GameHours.Desktop/LibraryToolbar.xaml.cs new file mode 100644 index 0000000..a5e4a32 --- /dev/null +++ b/src/GameHours.Desktop/LibraryToolbar.xaml.cs @@ -0,0 +1,60 @@ +using System.Windows; +using System.Windows.Controls; + +namespace GameHours.Desktop; + +public enum LibraryFilterScope +{ + All = 0, + Favorites = 1, + Running = 2, + Backlog = 3, + Playing = 4, + Completed = 5, + Abandoned = 6, + Hidden = 7 +} + +public partial class LibraryToolbar : UserControl +{ + private sealed record FilterOption(LibraryFilterScope Scope, string Label); + + public event Action? FilterChanged; + + public string SearchText => SearchBox.Text; + + public LibraryFilterScope Scope => + ScopeComboBox.SelectedItem is FilterOption option + ? option.Scope + : LibraryFilterScope.All; + + public LibraryToolbar() + { + InitializeComponent(); + ScopeComboBox.ItemsSource = new[] + { + new FilterOption(LibraryFilterScope.All, "Todos"), + new FilterOption(LibraryFilterScope.Favorites, "Favoritos"), + new FilterOption(LibraryFilterScope.Running, "En ejecución"), + new FilterOption(LibraryFilterScope.Backlog, "Pendientes"), + new FilterOption(LibraryFilterScope.Playing, "Jugando"), + new FilterOption(LibraryFilterScope.Completed, "Completados"), + new FilterOption(LibraryFilterScope.Abandoned, "Abandonados"), + new FilterOption(LibraryFilterScope.Hidden, "Ocultos") + }; + ScopeComboBox.SelectedIndex = 0; + } + + public void SetCount(int visibleCount, int totalCount) + { + CountTextBlock.Text = visibleCount == totalCount + ? totalCount == 1 ? "1 juego" : $"{totalCount} juegos" + : $"{visibleCount} de {totalCount}"; + } + + private void SearchBox_TextChanged(object sender, TextChangedEventArgs e) => + FilterChanged?.Invoke(); + + private void ScopeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) => + FilterChanged?.Invoke(); +} From 405cce0fcd9bf20158292bb5e10942a72f776ad3 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:10:33 +0200 Subject: [PATCH 07/52] feat: add library search filters and organization --- .../MainWindow.LibraryInteraction.cs | 357 +++++++++++++++++- 1 file changed, 354 insertions(+), 3 deletions(-) diff --git a/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs b/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs index d0ba7e2..b0802b1 100644 --- a/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs +++ b/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs @@ -1,31 +1,63 @@ using System.Collections; +using System.Collections.Specialized; +using System.Globalization; +using System.Text; using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; using System.Windows.Data; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Media3D; +using GameHours.Core.Domain; +using GameHours.Storage.Sqlite; namespace GameHours.Desktop; public partial class MainWindow { - private bool _librarySortConfigured; + private bool _libraryViewConfigured; private bool _activeGameCursor; + private readonly Dictionary _libraryPreferences = new(); + private readonly SemaphoreSlim _libraryPreferenceWriteGate = new(1, 1); + private LibraryToolbar? _libraryToolbar; + private ListCollectionView? _libraryCollectionView; + private SqliteLibraryGamePreferencesRepository? _libraryPreferencesRepository; + private Task? _libraryPreferencesLoadTask; protected override void OnContentRendered(EventArgs e) { base.OnContentRendered(e); - if (_librarySortConfigured) + if (_libraryViewConfigured) { return; } - _librarySortConfigured = true; + _libraryViewConfigured = true; + AttachLibraryToolbar(); if (CollectionViewSource.GetDefaultView(Games) is ListCollectionView view) { + _libraryCollectionView = view; view.CustomSort = new LibraryGameViewModelComparer(this); + view.Filter = ShouldDisplayLibraryRow; view.Refresh(); } + + Games.CollectionChanged += Games_CollectionChanged; + _libraryPreferencesLoadTask = LoadLibraryPreferencesAsync(); + UpdateLibraryVisibleCount(); + } + + protected override void OnClosed(EventArgs e) + { + Games.CollectionChanged -= Games_CollectionChanged; + if (_libraryToolbar is not null) + { + _libraryToolbar.FilterChanged -= LibraryToolbar_FilterChanged; + } + + _libraryPreferenceWriteGate.Dispose(); + base.OnClosed(e); } protected override void OnPreviewMouseLeftButtonUp(MouseButtonEventArgs e) @@ -54,6 +86,26 @@ protected override void OnPreviewMouseLeftButtonUp(MouseButtonEventArgs e) e.Handled = true; } + protected override void OnPreviewMouseRightButtonUp(MouseButtonEventArgs e) + { + base.OnPreviewMouseRightButtonUp(e); + if (e.Handled) + { + return; + } + + var game = FindDataContext(e.OriginalSource as DependencyObject); + if (game is null) + { + return; + } + + var menu = BuildLibraryContextMenu(game); + menu.Placement = PlacementMode.MousePoint; + menu.IsOpen = true; + e.Handled = true; + } + protected override void OnPreviewMouseMove(System.Windows.Input.MouseEventArgs e) { base.OnPreviewMouseMove(e); @@ -75,6 +127,305 @@ protected override void OnMouseLeave(System.Windows.Input.MouseEventArgs e) Cursor = null; } + private void AttachLibraryToolbar() + { + if (LibraryView.Child is not Grid grid || grid.RowDefinitions.Count < 3) + { + return; + } + + grid.RowDefinitions.Insert(1, new RowDefinition { Height = GridLength.Auto }); + foreach (UIElement child in grid.Children.Cast().ToArray()) + { + var row = Grid.GetRow(child); + if (row >= 1) + { + Grid.SetRow(child, row + 1); + } + } + + _libraryToolbar = new LibraryToolbar(); + _libraryToolbar.FilterChanged += LibraryToolbar_FilterChanged; + Grid.SetRow(_libraryToolbar, 1); + grid.Children.Add(_libraryToolbar); + } + + private async Task LoadLibraryPreferencesAsync() + { + try + { + if (string.IsNullOrWhiteSpace(_host.DatabasePath)) + { + return; + } + + var database = new GameHoursDatabase(_host.DatabasePath); + var repository = new SqliteLibraryGamePreferencesRepository(database); + var loaded = await repository.GetAllAsync(); + + _libraryPreferencesRepository = repository; + _libraryPreferences.Clear(); + foreach (var pair in loaded) + { + _libraryPreferences[pair.Key] = pair.Value; + } + + RefreshLibraryView(); + } + catch (Exception exception) + { + System.Windows.MessageBox.Show( + this, + exception.Message, + "No se pudo cargar la organización de la biblioteca", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + } + + private void Games_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => + UpdateLibraryVisibleCount(); + + private void LibraryToolbar_FilterChanged() + { + RefreshLibraryView(); + } + + private void RefreshLibraryView() + { + _libraryCollectionView?.Refresh(); + UpdateLibraryVisibleCount(); + } + + private void UpdateLibraryVisibleCount() + { + if (_libraryToolbar is null) + { + return; + } + + var visible = _libraryCollectionView?.Cast().Count() ?? Games.Count; + _libraryToolbar.SetCount(visible, Games.Count); + } + + private bool ShouldDisplayLibraryRow(object item) + { + if (item is not GameRowViewModel game) + { + return false; + } + + var preferences = GetLibraryPreferences(game.GameId); + var activeGames = ResolveActiveGames(_host.CurrentStatus); + return ShouldShowLibraryGame( + game, + preferences, + _libraryToolbar?.Scope ?? LibraryFilterScope.All, + _libraryToolbar?.SearchText, + activeGames); + } + + private LibraryGamePreferences GetLibraryPreferences(Guid gameId) => + _libraryPreferences.TryGetValue(gameId, out var preferences) + ? preferences + : new LibraryGamePreferences(gameId); + + private ContextMenu BuildLibraryContextMenu(GameRowViewModel game) + { + var preferences = GetLibraryPreferences(game.GameId); + var menu = new ContextMenu + { + Background = (Brush)FindResource("SurfaceBrush"), + Foreground = (Brush)FindResource("TextBrush") + }; + + var favorite = new MenuItem + { + Header = preferences.IsFavorite ? "★ Quitar de favoritos" : "☆ Añadir a favoritos" + }; + favorite.Click += async (_, _) => await SaveLibraryPreferencesAsync( + preferences with { IsFavorite = !preferences.IsFavorite }); + menu.Items.Add(favorite); + + var statusMenu = new MenuItem { Header = "Estado" }; + AddCompletionStatusItem(statusMenu, game.GameId, preferences, LibraryCompletionStatus.Unspecified, "Sin estado"); + AddCompletionStatusItem(statusMenu, game.GameId, preferences, LibraryCompletionStatus.Backlog, "Pendiente"); + AddCompletionStatusItem(statusMenu, game.GameId, preferences, LibraryCompletionStatus.Playing, "Jugando"); + AddCompletionStatusItem(statusMenu, game.GameId, preferences, LibraryCompletionStatus.Completed, "Completado"); + AddCompletionStatusItem(statusMenu, game.GameId, preferences, LibraryCompletionStatus.Abandoned, "Abandonado"); + menu.Items.Add(statusMenu); + + menu.Items.Add(new Separator()); + var hidden = new MenuItem + { + Header = preferences.IsHidden ? "Mostrar en la biblioteca" : "Ocultar de la biblioteca" + }; + hidden.Click += async (_, _) => await SaveLibraryPreferencesAsync( + preferences with { IsHidden = !preferences.IsHidden }); + menu.Items.Add(hidden); + + return menu; + } + + private void AddCompletionStatusItem( + MenuItem parent, + Guid gameId, + LibraryGamePreferences current, + LibraryCompletionStatus status, + string label) + { + var item = new MenuItem + { + Header = label, + IsCheckable = true, + IsChecked = current.CompletionStatus == status + }; + item.Click += async (_, _) => await SaveLibraryPreferencesAsync( + current with { GameId = gameId, CompletionStatus = status }); + parent.Items.Add(item); + } + + private async Task SaveLibraryPreferencesAsync(LibraryGamePreferences preferences) + { + try + { + if (_libraryPreferencesLoadTask is not null) + { + await _libraryPreferencesLoadTask; + } + + if (_libraryPreferencesRepository is null) + { + throw new InvalidOperationException("El almacenamiento de la biblioteca todavía no está disponible."); + } + + await _libraryPreferenceWriteGate.WaitAsync(); + try + { + await _libraryPreferencesRepository.SetAsync(preferences); + } + finally + { + _libraryPreferenceWriteGate.Release(); + } + + if (preferences.IsDefault) + { + _libraryPreferences.Remove(preferences.GameId); + } + else + { + _libraryPreferences[preferences.GameId] = preferences; + } + + RefreshLibraryView(); + } + catch (ObjectDisposedException) when (!IsLoaded) + { + } + catch (Exception exception) + { + System.Windows.MessageBox.Show( + this, + exception.Message, + "No se pudo guardar la organización del juego", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + } + + internal static bool ShouldShowLibraryGame( + GameRowViewModel game, + LibraryGamePreferences preferences, + LibraryFilterScope scope, + string? searchText, + IReadOnlyList activeGames) + { + ArgumentNullException.ThrowIfNull(game); + ArgumentNullException.ThrowIfNull(preferences); + ArgumentNullException.ThrowIfNull(activeGames); + + var hiddenScope = scope == LibraryFilterScope.Hidden; + if (hiddenScope != preferences.IsHidden) + { + return false; + } + + var matchesScope = scope switch + { + LibraryFilterScope.All or LibraryFilterScope.Hidden => true, + LibraryFilterScope.Favorites => preferences.IsFavorite, + LibraryFilterScope.Running => IsGameActive(game, activeGames), + LibraryFilterScope.Backlog => preferences.CompletionStatus == LibraryCompletionStatus.Backlog, + LibraryFilterScope.Playing => preferences.CompletionStatus == LibraryCompletionStatus.Playing, + LibraryFilterScope.Completed => preferences.CompletionStatus == LibraryCompletionStatus.Completed, + LibraryFilterScope.Abandoned => preferences.CompletionStatus == LibraryCompletionStatus.Abandoned, + _ => false + }; + + return matchesScope && MatchesLibrarySearch(game.Title, searchText); + } + + internal static bool MatchesLibrarySearch(string title, string? query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return true; + } + + var trimmed = query.Trim(); + var compare = CultureInfo.CurrentCulture.CompareInfo; + const CompareOptions options = CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace; + if (compare.IndexOf(title, trimmed, options) >= 0) + { + return true; + } + + var tokens = trimmed.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (tokens.Length > 1 && tokens.All(token => compare.IndexOf(title, token, options) >= 0)) + { + return true; + } + + if (tokens.Length == 1) + { + var acronym = BuildTitleAcronym(title); + return acronym.Length > 1 && compare.IsPrefix(acronym, tokens[0], options); + } + + return false; + } + + private static string BuildTitleAcronym(string title) + { + var builder = new StringBuilder(); + var atWordStart = true; + foreach (var character in title) + { + if (!char.IsLetterOrDigit(character)) + { + atWordStart = true; + continue; + } + + if (atWordStart) + { + builder.Append(character); + atWordStart = false; + } + } + + return builder.ToString(); + } + + private static bool IsGameActive( + GameRowViewModel game, + IReadOnlyList activeGames) => + activeGames.Any(active => + active.GameId != Guid.Empty + ? active.GameId == game.GameId + : string.Equals(active.Title, game.Title, StringComparison.OrdinalIgnoreCase)); + internal static GameRowViewModel? ResolveActiveGameTarget( ActiveGameRowViewModel activeGame, IEnumerable games) From 7c02cb7f48c8403acc32eccac0ec63c91dfeb6b3 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:10:57 +0200 Subject: [PATCH 08/52] test: cover library preference persistence --- ...teLibraryGamePreferencesRepositoryTests.cs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/GameHours.Tests/SqliteLibraryGamePreferencesRepositoryTests.cs diff --git a/tests/GameHours.Tests/SqliteLibraryGamePreferencesRepositoryTests.cs b/tests/GameHours.Tests/SqliteLibraryGamePreferencesRepositoryTests.cs new file mode 100644 index 0000000..6d0dbb0 --- /dev/null +++ b/tests/GameHours.Tests/SqliteLibraryGamePreferencesRepositoryTests.cs @@ -0,0 +1,89 @@ +using GameHours.Core.Domain; +using GameHours.Storage.Sqlite; + +namespace GameHours.Tests; + +public sealed class SqliteLibraryGamePreferencesRepositoryTests : IDisposable +{ + private readonly string _directory = Path.Combine( + Path.GetTempPath(), + "gamehours-library-preferences", + Guid.NewGuid().ToString("N")); + + [Fact] + public async Task RoundTrip_PreservesFavoriteHiddenAndCompletionStatus() + { + Directory.CreateDirectory(_directory); + var database = new GameHoursDatabase(Path.Combine(_directory, "gamehours.db")); + await database.InitializeAsync(); + var games = new SqliteGameRepository(database); + var game = new TrackedGame(Guid.NewGuid(), "Library Test"); + await games.UpsertAsync(game); + var repository = new SqliteLibraryGamePreferencesRepository(database); + + await repository.SetAsync(new LibraryGamePreferences( + game.Id, + IsFavorite: true, + IsHidden: true, + CompletionStatus: LibraryCompletionStatus.Completed)); + + var loaded = await repository.GetAsync(game.Id); + Assert.True(loaded.IsFavorite); + Assert.True(loaded.IsHidden); + Assert.Equal(LibraryCompletionStatus.Completed, loaded.CompletionStatus); + + var all = await repository.GetAllAsync(); + Assert.Equal(loaded, all[game.Id]); + } + + [Fact] + public async Task SavingDefaultPreferences_RemovesSparseRow() + { + Directory.CreateDirectory(_directory); + var database = new GameHoursDatabase(Path.Combine(_directory, "gamehours.db")); + await database.InitializeAsync(); + var games = new SqliteGameRepository(database); + var game = new TrackedGame(Guid.NewGuid(), "Sparse Test"); + await games.UpsertAsync(game); + var repository = new SqliteLibraryGamePreferencesRepository(database); + + await repository.SetAsync(new LibraryGamePreferences(game.Id, IsFavorite: true)); + await repository.SetAsync(new LibraryGamePreferences(game.Id)); + + var loaded = await repository.GetAsync(game.Id); + Assert.True(loaded.IsDefault); + Assert.Empty(await repository.GetAllAsync()); + } + + [Fact] + public async Task PreferencesFollowGameForeignKeyCascade() + { + Directory.CreateDirectory(_directory); + var database = new GameHoursDatabase(Path.Combine(_directory, "gamehours.db")); + await database.InitializeAsync(); + var gameId = Guid.NewGuid(); + var games = new SqliteGameRepository(database); + await games.UpsertAsync(new TrackedGame(gameId, "Cascade Test")); + var repository = new SqliteLibraryGamePreferencesRepository(database); + await repository.SetAsync(new LibraryGamePreferences(gameId, IsFavorite: true)); + + await using (var connection = database.OpenConnection()) + await using (var command = connection.CreateCommand()) + { + command.CommandText = "DELETE FROM games WHERE id = $game_id;"; + command.Parameters.AddWithValue("$game_id", gameId.ToString("D")); + await command.ExecuteNonQueryAsync(); + } + + Assert.Empty(await repository.GetAllAsync()); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } +} From 18ef3b2cca27164455199dd66e022f7e62169cb8 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:11:45 +0200 Subject: [PATCH 09/52] test: validate library preferences schema v8 --- tests/GameHours.Tests/SqliteMigrationTests.cs | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/tests/GameHours.Tests/SqliteMigrationTests.cs b/tests/GameHours.Tests/SqliteMigrationTests.cs index e33e58c..c940325 100644 --- a/tests/GameHours.Tests/SqliteMigrationTests.cs +++ b/tests/GameHours.Tests/SqliteMigrationTests.cs @@ -26,7 +26,7 @@ public async Task LegacyDatabaseWithoutUserVersionMigratesToCurrentSchema() await using var verify = database.OpenConnection(); await using var versionCommand = verify.CreateCommand(); versionCommand.CommandText = "PRAGMA user_version;"; - Assert.Equal(7L, Convert.ToInt64(await versionCommand.ExecuteScalarAsync())); + Assert.Equal(8L, Convert.ToInt64(await versionCommand.ExecuteScalarAsync())); await using var tableCommand = verify.CreateCommand(); tableCommand.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('game_candidates', 'session_activity');"; Assert.Equal(2L, Convert.ToInt64(await tableCommand.ExecuteScalarAsync())); @@ -36,6 +36,9 @@ public async Task LegacyDatabaseWithoutUserVersionMigratesToCurrentSchema() await using var evidenceTable = verify.CreateCommand(); evidenceTable.CommandText = "SELECT COUNT(*) FROM pragma_table_info('achievement_unlock_evidence');"; Assert.Equal(11L, Convert.ToInt64(await evidenceTable.ExecuteScalarAsync())); + await using var libraryPreferencesTable = verify.CreateCommand(); + libraryPreferencesTable.CommandText = "SELECT COUNT(*) FROM pragma_table_info('game_library_preferences');"; + Assert.Equal(5L, Convert.ToInt64(await libraryPreferencesTable.ExecuteScalarAsync())); } [Fact] @@ -48,7 +51,7 @@ public async Task InitializeIsIdempotentAtCurrentVersion() await using var connection = database.OpenConnection(); await using var command = connection.CreateCommand(); command.CommandText = "PRAGMA user_version;"; - Assert.Equal(7L, Convert.ToInt64(await command.ExecuteScalarAsync())); + Assert.Equal(8L, Convert.ToInt64(await command.ExecuteScalarAsync())); } [Fact] @@ -62,6 +65,7 @@ public async Task VersionSixDatabaseMigratesToAchievementEvidenceSchema() { downgrade.CommandText = """ DROP TABLE achievement_unlock_evidence; + DROP TABLE game_library_preferences; PRAGMA user_version = 6; UPDATE schema_info SET version = 6; """; @@ -73,10 +77,13 @@ public async Task VersionSixDatabaseMigratesToAchievementEvidenceSchema() await using var verify = database.OpenConnection(); await using var version = verify.CreateCommand(); version.CommandText = "PRAGMA user_version;"; - Assert.Equal(7L, Convert.ToInt64(await version.ExecuteScalarAsync())); + Assert.Equal(8L, Convert.ToInt64(await version.ExecuteScalarAsync())); await using var table = verify.CreateCommand(); table.CommandText = "SELECT COUNT(*) FROM pragma_table_info('achievement_unlock_evidence');"; Assert.Equal(11L, Convert.ToInt64(await table.ExecuteScalarAsync())); + await using var preferences = verify.CreateCommand(); + preferences.CommandText = "SELECT COUNT(*) FROM pragma_table_info('game_library_preferences');"; + Assert.Equal(5L, Convert.ToInt64(await preferences.ExecuteScalarAsync())); } [Fact] @@ -101,6 +108,27 @@ public async Task CurrentVersionRepairsMissingEvidenceTableWithoutDuplicateMigra Assert.Equal(11L, Convert.ToInt64(await table.ExecuteScalarAsync())); } + [Fact] + public async Task CurrentVersionRepairsMissingLibraryPreferencesTable() + { + Directory.CreateDirectory(_directory); + var database = new GameHoursDatabase(Path.Combine(_directory, "library-shape.db")); + await database.InitializeAsync(); + await using (var connection = database.OpenConnection()) + await using (var command = connection.CreateCommand()) + { + command.CommandText = "DROP TABLE game_library_preferences;"; + await command.ExecuteNonQueryAsync(); + } + + await database.InitializeAsync(); + + await using var verify = database.OpenConnection(); + await using var table = verify.CreateCommand(); + table.CommandText = "SELECT COUNT(*) FROM pragma_table_info('game_library_preferences');"; + Assert.Equal(5L, Convert.ToInt64(await table.ExecuteScalarAsync())); + } + [Fact] public async Task CurrentSchemaRejectsActiveDurationWhenAfkEstimationIsDisabled() { From f8172258cfd06418f0448731549eb32273b275d2 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:12:15 +0200 Subject: [PATCH 10/52] test: cover library search and filters --- .../MainWindowLibraryInteractionTests.cs | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/GameHours.Windows.Tests/MainWindowLibraryInteractionTests.cs b/tests/GameHours.Windows.Tests/MainWindowLibraryInteractionTests.cs index 9be1b7b..79a0b7f 100644 --- a/tests/GameHours.Windows.Tests/MainWindowLibraryInteractionTests.cs +++ b/tests/GameHours.Windows.Tests/MainWindowLibraryInteractionTests.cs @@ -1,3 +1,4 @@ +using GameHours.Core.Domain; using GameHours.Desktop; namespace GameHours.Windows.Tests; @@ -58,6 +59,75 @@ public void LibraryOrder_UsesLatestStartWhenSeveralGamesAreRunning() Assert.Equal(new[] { later.GameId, earlier.GameId }, ordered); } + [Theory] + [InlineData("Pokémon Café ReMix", "pokemon cafe")] + [InlineData("Gothic 1 Remake", "g1r")] + [InlineData("Click.the.Button", "ctb")] + [InlineData("The Elder Scrolls V", "elder scrolls")] + public void LibrarySearch_IsCaseAccentAndAcronymFriendly(string title, string query) + { + Assert.True(MainWindow.MatchesLibrarySearch(title, query)); + } + + [Fact] + public void DefaultLibraryScope_ExcludesHiddenGames() + { + var game = new MainWindow.GameRowViewModel(CreateGame("Hidden", null, TimeSpan.Zero)); + var preferences = new LibraryGamePreferences(game.GameId, IsHidden: true); + + Assert.False(MainWindow.ShouldShowLibraryGame( + game, + preferences, + LibraryFilterScope.All, + searchText: null, + Array.Empty())); + Assert.True(MainWindow.ShouldShowLibraryGame( + game, + preferences, + LibraryFilterScope.Hidden, + searchText: null, + Array.Empty())); + } + + [Fact] + public void LibraryScopes_FilterFavoriteCompletionAndRunningIndependently() + { + var game = new MainWindow.GameRowViewModel(CreateGame("Scoped", null, TimeSpan.Zero)); + var preferences = new LibraryGamePreferences( + game.GameId, + IsFavorite: true, + CompletionStatus: LibraryCompletionStatus.Playing); + var active = new[] + { + new DesktopActiveGame(game.GameId, game.Title, DateTimeOffset.UtcNow) + }; + + Assert.True(MainWindow.ShouldShowLibraryGame(game, preferences, LibraryFilterScope.Favorites, null, active)); + Assert.True(MainWindow.ShouldShowLibraryGame(game, preferences, LibraryFilterScope.Playing, null, active)); + Assert.True(MainWindow.ShouldShowLibraryGame(game, preferences, LibraryFilterScope.Running, null, active)); + Assert.False(MainWindow.ShouldShowLibraryGame(game, preferences, LibraryFilterScope.Completed, null, active)); + } + + [Fact] + public void LibrarySearch_ComposesWithScope() + { + var game = new MainWindow.GameRowViewModel(CreateGame("Gothic 1 Remake", null, TimeSpan.Zero)); + var preferences = new LibraryGamePreferences(game.GameId, IsFavorite: true); + + Assert.True(MainWindow.ShouldShowLibraryGame( + game, + preferences, + LibraryFilterScope.Favorites, + "gothic", + Array.Empty())); + Assert.False(MainWindow.ShouldShowLibraryGame( + game, + preferences, + LibraryFilterScope.Favorites, + "witcher", + Array.Empty())); + } + [Fact] public void ActiveGameClickTarget_ResolvesLibraryRowByStableGameId() { From bb79e1b114ca4ed8057a93464bfaba3697e218f4 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:16:21 +0200 Subject: [PATCH 11/52] fix: disambiguate WPF brush --- src/GameHours.Desktop/MainWindow.LibraryInteraction.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs b/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs index b0802b1..934c7c9 100644 --- a/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs +++ b/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs @@ -235,8 +235,8 @@ private ContextMenu BuildLibraryContextMenu(GameRowViewModel game) var preferences = GetLibraryPreferences(game.GameId); var menu = new ContextMenu { - Background = (Brush)FindResource("SurfaceBrush"), - Foreground = (Brush)FindResource("TextBrush") + Background = (System.Windows.Media.Brush)FindResource("SurfaceBrush"), + Foreground = (System.Windows.Media.Brush)FindResource("TextBrush") }; var favorite = new MenuItem From 682501163b01b9c30638832c7103be006e5d5597 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:18:20 +0200 Subject: [PATCH 12/52] fix: serialize library preference mutations safely --- .../MainWindow.LibraryInteraction.cs | 65 +++++++++++++------ 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs b/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs index 934c7c9..7873f25 100644 --- a/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs +++ b/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs @@ -56,7 +56,6 @@ protected override void OnClosed(EventArgs e) _libraryToolbar.FilterChanged -= LibraryToolbar_FilterChanged; } - _libraryPreferenceWriteGate.Dispose(); base.OnClosed(e); } @@ -86,7 +85,7 @@ protected override void OnPreviewMouseLeftButtonUp(MouseButtonEventArgs e) e.Handled = true; } - protected override void OnPreviewMouseRightButtonUp(MouseButtonEventArgs e) + protected override async void OnPreviewMouseRightButtonUp(MouseButtonEventArgs e) { base.OnPreviewMouseRightButtonUp(e); if (e.Handled) @@ -100,6 +99,16 @@ protected override void OnPreviewMouseRightButtonUp(MouseButtonEventArgs e) return; } + if (_libraryPreferencesLoadTask is not null) + { + await _libraryPreferencesLoadTask; + } + + if (!IsLoaded) + { + return; + } + var menu = BuildLibraryContextMenu(game); menu.Placement = PlacementMode.MousePoint; menu.IsOpen = true; @@ -243,8 +252,9 @@ private ContextMenu BuildLibraryContextMenu(GameRowViewModel game) { Header = preferences.IsFavorite ? "★ Quitar de favoritos" : "☆ Añadir a favoritos" }; - favorite.Click += async (_, _) => await SaveLibraryPreferencesAsync( - preferences with { IsFavorite = !preferences.IsFavorite }); + favorite.Click += async (_, _) => await UpdateLibraryPreferencesAsync( + game.GameId, + current => current with { IsFavorite = !current.IsFavorite }); menu.Items.Add(favorite); var statusMenu = new MenuItem { Header = "Estado" }; @@ -260,8 +270,9 @@ private ContextMenu BuildLibraryContextMenu(GameRowViewModel game) { Header = preferences.IsHidden ? "Mostrar en la biblioteca" : "Ocultar de la biblioteca" }; - hidden.Click += async (_, _) => await SaveLibraryPreferencesAsync( - preferences with { IsHidden = !preferences.IsHidden }); + hidden.Click += async (_, _) => await UpdateLibraryPreferencesAsync( + game.GameId, + current => current with { IsHidden = !current.IsHidden }); menu.Items.Add(hidden); return menu; @@ -280,13 +291,18 @@ private void AddCompletionStatusItem( IsCheckable = true, IsChecked = current.CompletionStatus == status }; - item.Click += async (_, _) => await SaveLibraryPreferencesAsync( - current with { GameId = gameId, CompletionStatus = status }); + item.Click += async (_, _) => await UpdateLibraryPreferencesAsync( + gameId, + latest => latest with { CompletionStatus = status }); parent.Items.Add(item); } - private async Task SaveLibraryPreferencesAsync(LibraryGamePreferences preferences) + private async Task UpdateLibraryPreferencesAsync( + Guid gameId, + Func update) { + ArgumentNullException.ThrowIfNull(update); + try { if (_libraryPreferencesLoadTask is not null) @@ -302,29 +318,36 @@ private async Task SaveLibraryPreferencesAsync(LibraryGamePreferences preference await _libraryPreferenceWriteGate.WaitAsync(); try { + var preferences = update(GetLibraryPreferences(gameId)); + if (preferences.GameId != gameId) + { + throw new InvalidOperationException("La actualización de biblioteca cambió la identidad del juego."); + } + await _libraryPreferencesRepository.SetAsync(preferences); + if (preferences.IsDefault) + { + _libraryPreferences.Remove(gameId); + } + else + { + _libraryPreferences[gameId] = preferences; + } } finally { _libraryPreferenceWriteGate.Release(); } - if (preferences.IsDefault) - { - _libraryPreferences.Remove(preferences.GameId); - } - else - { - _libraryPreferences[preferences.GameId] = preferences; - } - RefreshLibraryView(); } - catch (ObjectDisposedException) when (!IsLoaded) - { - } catch (Exception exception) { + if (!IsLoaded) + { + return; + } + System.Windows.MessageBox.Show( this, exception.Message, From dbdf4d945860ee6cc178289d4592028a3f199b57 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:42:57 +0200 Subject: [PATCH 13/52] feat: align library status with gestor juegos --- src/GameHours.Core/Domain/LibraryCompletionStatus.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/GameHours.Core/Domain/LibraryCompletionStatus.cs b/src/GameHours.Core/Domain/LibraryCompletionStatus.cs index 4d27c73..aee2e21 100644 --- a/src/GameHours.Core/Domain/LibraryCompletionStatus.cs +++ b/src/GameHours.Core/Domain/LibraryCompletionStatus.cs @@ -6,5 +6,8 @@ public enum LibraryCompletionStatus Backlog = 1, Playing = 2, Completed = 3, - Abandoned = 4 + Abandoned = 4, + // Keep this appended so v8 development databases that already stored 3/4 retain their + // Completed/Abandoned meaning after Gestor de Juegos compatibility is added. + Paused = 5 } From c358c2f42764bc727f0e87f764554a01f062f454 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:43:10 +0200 Subject: [PATCH 14/52] feat: expose paused library status --- src/GameHours.Desktop/LibraryToolbar.xaml.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/GameHours.Desktop/LibraryToolbar.xaml.cs b/src/GameHours.Desktop/LibraryToolbar.xaml.cs index a5e4a32..8446c70 100644 --- a/src/GameHours.Desktop/LibraryToolbar.xaml.cs +++ b/src/GameHours.Desktop/LibraryToolbar.xaml.cs @@ -12,7 +12,8 @@ public enum LibraryFilterScope Playing = 4, Completed = 5, Abandoned = 6, - Hidden = 7 + Hidden = 7, + Paused = 8 } public partial class LibraryToolbar : UserControl @@ -38,6 +39,7 @@ public LibraryToolbar() new FilterOption(LibraryFilterScope.Running, "En ejecución"), new FilterOption(LibraryFilterScope.Backlog, "Pendientes"), new FilterOption(LibraryFilterScope.Playing, "Jugando"), + new FilterOption(LibraryFilterScope.Paused, "Pausados"), new FilterOption(LibraryFilterScope.Completed, "Completados"), new FilterOption(LibraryFilterScope.Abandoned, "Abandonados"), new FilterOption(LibraryFilterScope.Hidden, "Ocultos") From d846a706ecd076c5dbe6784d1de2cd1a2faebb56 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:43:34 +0200 Subject: [PATCH 15/52] fix: validate all supported library statuses --- .../Sqlite/SqliteLibraryGamePreferencesRepository.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GameHours.Storage/Sqlite/SqliteLibraryGamePreferencesRepository.cs b/src/GameHours.Storage/Sqlite/SqliteLibraryGamePreferencesRepository.cs index 5098c5b..3b621fc 100644 --- a/src/GameHours.Storage/Sqlite/SqliteLibraryGamePreferencesRepository.cs +++ b/src/GameHours.Storage/Sqlite/SqliteLibraryGamePreferencesRepository.cs @@ -117,7 +117,7 @@ private static LibraryCompletionStatus ReadCompletionStatus(int value) private static void ValidateCompletionStatus(LibraryCompletionStatus status) { - if (status is < LibraryCompletionStatus.Unspecified or > LibraryCompletionStatus.Abandoned) + if (!Enum.IsDefined(status)) { throw new InvalidDataException($"Unsupported library completion status: {(int)status}."); } From 394ea021e153a758dfb3a547a47c1246fa02680d Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:44:42 +0200 Subject: [PATCH 16/52] feat: add paused library filtering --- src/GameHours.Desktop/MainWindow.LibraryInteraction.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs b/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs index 7873f25..c2f85ca 100644 --- a/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs +++ b/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs @@ -261,6 +261,7 @@ private ContextMenu BuildLibraryContextMenu(GameRowViewModel game) AddCompletionStatusItem(statusMenu, game.GameId, preferences, LibraryCompletionStatus.Unspecified, "Sin estado"); AddCompletionStatusItem(statusMenu, game.GameId, preferences, LibraryCompletionStatus.Backlog, "Pendiente"); AddCompletionStatusItem(statusMenu, game.GameId, preferences, LibraryCompletionStatus.Playing, "Jugando"); + AddCompletionStatusItem(statusMenu, game.GameId, preferences, LibraryCompletionStatus.Paused, "Pausado"); AddCompletionStatusItem(statusMenu, game.GameId, preferences, LibraryCompletionStatus.Completed, "Completado"); AddCompletionStatusItem(statusMenu, game.GameId, preferences, LibraryCompletionStatus.Abandoned, "Abandonado"); menu.Items.Add(statusMenu); @@ -381,6 +382,7 @@ internal static bool ShouldShowLibraryGame( LibraryFilterScope.Running => IsGameActive(game, activeGames), LibraryFilterScope.Backlog => preferences.CompletionStatus == LibraryCompletionStatus.Backlog, LibraryFilterScope.Playing => preferences.CompletionStatus == LibraryCompletionStatus.Playing, + LibraryFilterScope.Paused => preferences.CompletionStatus == LibraryCompletionStatus.Paused, LibraryFilterScope.Completed => preferences.CompletionStatus == LibraryCompletionStatus.Completed, LibraryFilterScope.Abandoned => preferences.CompletionStatus == LibraryCompletionStatus.Abandoned, _ => false From afb96c9e894a125be495c1d2c57933d1ad18d587 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:44:57 +0200 Subject: [PATCH 17/52] feat: add backend-neutral external game identity --- .../Domain/GameExternalIdentity.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/GameHours.Core/Domain/GameExternalIdentity.cs diff --git a/src/GameHours.Core/Domain/GameExternalIdentity.cs b/src/GameHours.Core/Domain/GameExternalIdentity.cs new file mode 100644 index 0000000..d070b22 --- /dev/null +++ b/src/GameHours.Core/Domain/GameExternalIdentity.cs @@ -0,0 +1,49 @@ +namespace GameHours.Core.Domain; + +/// +/// Stable identity assigned by an external catalogue/platform. GameHours keeps its own UUID as +/// the tracking identity; these values exist only to correlate that UUID with optional sources. +/// +public sealed record GameExternalIdentity +{ + public string Provider { get; } + public string ExternalId { get; } + + public GameExternalIdentity(string provider, string externalId) + { + if (string.IsNullOrWhiteSpace(provider)) + { + throw new ArgumentException("External identity provider cannot be empty.", nameof(provider)); + } + + if (string.IsNullOrWhiteSpace(externalId)) + { + throw new ArgumentException("External identity value cannot be empty.", nameof(externalId)); + } + + Provider = provider.Trim().ToLowerInvariant(); + ExternalId = externalId.Trim(); + } +} + +public static class GameExternalIdentityProviders +{ + public const string Steam = "steam"; + public const string Epic = "epic"; + public const string Gog = "gog"; + public const string Igdb = "igdb"; + + public static GameExternalIdentity? FromDiscoveredGame(DiscoveredGame game) + { + ArgumentNullException.ThrowIfNull(game); + var provider = game.Source switch + { + GameDiscoverySource.Steam => Steam, + GameDiscoverySource.Epic => Epic, + GameDiscoverySource.Gog => Gog, + _ => null + }; + + return provider is null ? null : new GameExternalIdentity(provider, game.ExternalId); + } +} From eb64ac59b61c43a41ce1e99396fb7c2d89ffb06b Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:45:24 +0200 Subject: [PATCH 18/52] feat: persist external game identities --- .../SqliteGameExternalIdentityRepository.cs | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 src/GameHours.Storage/Sqlite/SqliteGameExternalIdentityRepository.cs diff --git a/src/GameHours.Storage/Sqlite/SqliteGameExternalIdentityRepository.cs b/src/GameHours.Storage/Sqlite/SqliteGameExternalIdentityRepository.cs new file mode 100644 index 0000000..fa866b2 --- /dev/null +++ b/src/GameHours.Storage/Sqlite/SqliteGameExternalIdentityRepository.cs @@ -0,0 +1,151 @@ +using GameHours.Core.Domain; +using Microsoft.Data.Sqlite; + +namespace GameHours.Storage.Sqlite; + +public sealed class SqliteGameExternalIdentityRepository +{ + private readonly GameHoursDatabase _database; + + public SqliteGameExternalIdentityRepository(GameHoursDatabase database) + { + _database = database ?? throw new ArgumentNullException(nameof(database)); + } + + public async Task UpsertAsync( + Guid gameId, + GameExternalIdentity identity, + CancellationToken cancellationToken = default) + { + ValidateGameId(gameId); + ArgumentNullException.ThrowIfNull(identity); + + await using var connection = _database.OpenConnection(); + await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken); + await UpsertCoreAsync(connection, transaction, gameId, identity, cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + + public async Task UpsertManyAsync( + IEnumerable<(Guid GameId, GameExternalIdentity Identity)> links, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(links); + var materialized = links.ToArray(); + if (materialized.Length == 0) + { + return; + } + + foreach (var link in materialized) + { + ValidateGameId(link.GameId); + ArgumentNullException.ThrowIfNull(link.Identity); + } + + await using var connection = _database.OpenConnection(); + await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken); + foreach (var link in materialized) + { + await UpsertCoreAsync(connection, transaction, link.GameId, link.Identity, cancellationToken); + } + + await transaction.CommitAsync(cancellationToken); + } + + public async Task> GetForGameAsync( + Guid gameId, + CancellationToken cancellationToken = default) + { + ValidateGameId(gameId); + var result = new List(); + await using var connection = _database.OpenConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT provider, external_id + FROM game_external_identities + WHERE game_id = $game_id + ORDER BY provider COLLATE NOCASE, external_id COLLATE NOCASE; + """; + command.Parameters.AddWithValue("$game_id", gameId.ToString("D")); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + result.Add(new GameExternalIdentity(reader.GetString(0), reader.GetString(1))); + } + + return result; + } + + public async Task FindGameIdAsync( + GameExternalIdentity identity, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(identity); + await using var connection = _database.OpenConnection(); + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT game_id + FROM game_external_identities + WHERE provider = $provider COLLATE NOCASE + AND external_id = $external_id COLLATE NOCASE + LIMIT 1; + """; + command.Parameters.AddWithValue("$provider", identity.Provider); + command.Parameters.AddWithValue("$external_id", identity.ExternalId); + var value = await command.ExecuteScalarAsync(cancellationToken); + return value is string text ? Guid.Parse(text) : null; + } + + private static async Task UpsertCoreAsync( + SqliteConnection connection, + SqliteTransaction transaction, + Guid gameId, + GameExternalIdentity identity, + CancellationToken cancellationToken) + { + await using (var ownership = connection.CreateCommand()) + { + ownership.Transaction = transaction; + ownership.CommandText = """ + SELECT game_id + FROM game_external_identities + WHERE provider = $provider COLLATE NOCASE + AND external_id = $external_id COLLATE NOCASE + LIMIT 1; + """; + ownership.Parameters.AddWithValue("$provider", identity.Provider); + ownership.Parameters.AddWithValue("$external_id", identity.ExternalId); + var existing = await ownership.ExecuteScalarAsync(cancellationToken); + if (existing is string existingGameId && + !string.Equals(existingGameId, gameId.ToString("D"), StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"External game identity {identity.Provider}:{identity.ExternalId} is already linked to another GameHours game."); + } + } + + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = """ + INSERT INTO game_external_identities(game_id, provider, external_id, updated_at_utc) + VALUES($game_id, $provider, $external_id, $updated_at_utc) + ON CONFLICT(game_id, provider, external_id) DO UPDATE SET + updated_at_utc = excluded.updated_at_utc; + """; + command.Parameters.AddWithValue("$game_id", gameId.ToString("D")); + command.Parameters.AddWithValue("$provider", identity.Provider); + command.Parameters.AddWithValue("$external_id", identity.ExternalId); + command.Parameters.AddWithValue("$updated_at_utc", SqliteTime.Serialize(DateTimeOffset.UtcNow)); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private static void ValidateGameId(Guid gameId) + { + if (gameId == Guid.Empty) + { + throw new ArgumentException("Game id cannot be empty.", nameof(gameId)); + } + } +} From 634696f4ea45fe8afb364e600f48d36a34e5a910 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:46:16 +0200 Subject: [PATCH 19/52] feat: store optional external catalogue identities --- .../Sqlite/GameHoursDatabase.cs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/GameHours.Storage/Sqlite/GameHoursDatabase.cs b/src/GameHours.Storage/Sqlite/GameHoursDatabase.cs index bfe322f..14a4509 100644 --- a/src/GameHours.Storage/Sqlite/GameHoursDatabase.cs +++ b/src/GameHours.Storage/Sqlite/GameHoursDatabase.cs @@ -112,9 +112,9 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) await SetVersionAsync(connection, transaction, version, cancellationToken); } - // Library preferences are additive and sparse. Re-running CREATE TABLE IF NOT EXISTS also - // repairs a development/restore database whose version marker advanced before the table - // reached disk, without mutating any existing preference rows. + // Library state and external catalogue identities are additive. Re-running CREATE TABLE + // IF NOT EXISTS also repairs a development/restore database whose version marker advanced + // before either v8 table reached disk, without mutating existing rows. await ExecuteAsync(connection, transaction, MigrationV8, cancellationToken); if (version < 8) { @@ -334,9 +334,20 @@ CREATE TABLE IF NOT EXISTS game_library_preferences ( game_id TEXT PRIMARY KEY REFERENCES games(id) ON DELETE CASCADE, is_favorite INTEGER NOT NULL DEFAULT 0 CHECK (is_favorite IN (0, 1)), is_hidden INTEGER NOT NULL DEFAULT 0 CHECK (is_hidden IN (0, 1)), - completion_status INTEGER NOT NULL DEFAULT 0 CHECK (completion_status IN (0, 1, 2, 3, 4)), + completion_status INTEGER NOT NULL DEFAULT 0 CHECK (completion_status IN (0, 1, 2, 3, 4, 5)), updated_at_utc TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS game_external_identities ( + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + provider TEXT NOT NULL COLLATE NOCASE CHECK (length(trim(provider)) > 0), + external_id TEXT NOT NULL COLLATE NOCASE CHECK (length(trim(external_id)) > 0), + updated_at_utc TEXT NOT NULL, + PRIMARY KEY (game_id, provider, external_id), + UNIQUE (provider, external_id) + ); + CREATE INDEX IF NOT EXISTS idx_game_external_identities_game + ON game_external_identities(game_id, provider); """; private const string AchievementCompletionBackfill = """ From 006c02601d38d6a187a4e9eae0c0cc1d3326b1b7 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:47:21 +0200 Subject: [PATCH 20/52] test: cover library compatibility schema --- tests/GameHours.Tests/SqliteMigrationTests.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/GameHours.Tests/SqliteMigrationTests.cs b/tests/GameHours.Tests/SqliteMigrationTests.cs index c940325..77129c0 100644 --- a/tests/GameHours.Tests/SqliteMigrationTests.cs +++ b/tests/GameHours.Tests/SqliteMigrationTests.cs @@ -39,6 +39,9 @@ public async Task LegacyDatabaseWithoutUserVersionMigratesToCurrentSchema() await using var libraryPreferencesTable = verify.CreateCommand(); libraryPreferencesTable.CommandText = "SELECT COUNT(*) FROM pragma_table_info('game_library_preferences');"; Assert.Equal(5L, Convert.ToInt64(await libraryPreferencesTable.ExecuteScalarAsync())); + await using var externalIdentityTable = verify.CreateCommand(); + externalIdentityTable.CommandText = "SELECT COUNT(*) FROM pragma_table_info('game_external_identities');"; + Assert.Equal(4L, Convert.ToInt64(await externalIdentityTable.ExecuteScalarAsync())); } [Fact] @@ -66,6 +69,7 @@ public async Task VersionSixDatabaseMigratesToAchievementEvidenceSchema() downgrade.CommandText = """ DROP TABLE achievement_unlock_evidence; DROP TABLE game_library_preferences; + DROP TABLE game_external_identities; PRAGMA user_version = 6; UPDATE schema_info SET version = 6; """; @@ -84,6 +88,9 @@ public async Task VersionSixDatabaseMigratesToAchievementEvidenceSchema() await using var preferences = verify.CreateCommand(); preferences.CommandText = "SELECT COUNT(*) FROM pragma_table_info('game_library_preferences');"; Assert.Equal(5L, Convert.ToInt64(await preferences.ExecuteScalarAsync())); + await using var externalIdentities = verify.CreateCommand(); + externalIdentities.CommandText = "SELECT COUNT(*) FROM pragma_table_info('game_external_identities');"; + Assert.Equal(4L, Convert.ToInt64(await externalIdentities.ExecuteScalarAsync())); } [Fact] @@ -129,6 +136,27 @@ public async Task CurrentVersionRepairsMissingLibraryPreferencesTable() Assert.Equal(5L, Convert.ToInt64(await table.ExecuteScalarAsync())); } + [Fact] + public async Task CurrentVersionRepairsMissingExternalIdentityTable() + { + Directory.CreateDirectory(_directory); + var database = new GameHoursDatabase(Path.Combine(_directory, "external-identity-shape.db")); + await database.InitializeAsync(); + await using (var connection = database.OpenConnection()) + await using (var command = connection.CreateCommand()) + { + command.CommandText = "DROP TABLE game_external_identities;"; + await command.ExecuteNonQueryAsync(); + } + + await database.InitializeAsync(); + + await using var verify = database.OpenConnection(); + await using var table = verify.CreateCommand(); + table.CommandText = "SELECT COUNT(*) FROM pragma_table_info('game_external_identities');"; + Assert.Equal(4L, Convert.ToInt64(await table.ExecuteScalarAsync())); + } + [Fact] public async Task CurrentSchemaRejectsActiveDurationWhenAfkEstimationIsDisabled() { From 9cc3cce56effc2fa33b7376c11643b232bf2244e Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:47:37 +0200 Subject: [PATCH 21/52] test: cover external identity persistence --- ...liteGameExternalIdentityRepositoryTests.cs | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/GameHours.Tests/SqliteGameExternalIdentityRepositoryTests.cs diff --git a/tests/GameHours.Tests/SqliteGameExternalIdentityRepositoryTests.cs b/tests/GameHours.Tests/SqliteGameExternalIdentityRepositoryTests.cs new file mode 100644 index 0000000..8f57e90 --- /dev/null +++ b/tests/GameHours.Tests/SqliteGameExternalIdentityRepositoryTests.cs @@ -0,0 +1,83 @@ +using GameHours.Core.Domain; +using GameHours.Storage.Sqlite; + +namespace GameHours.Tests; + +public sealed class SqliteGameExternalIdentityRepositoryTests : IDisposable +{ + private readonly string _directory = Path.Combine( + Path.GetTempPath(), + "gamehours-external-identities", + Guid.NewGuid().ToString("N")); + + [Fact] + public async Task RoundTrip_FindsGameByProviderScopedIdentity() + { + Directory.CreateDirectory(_directory); + var database = new GameHoursDatabase(Path.Combine(_directory, "gamehours.db")); + await database.InitializeAsync(); + var game = new TrackedGame(Guid.NewGuid(), "External identity test"); + await new SqliteGameRepository(database).UpsertAsync(game); + var repository = new SqliteGameExternalIdentityRepository(database); + var identity = new GameExternalIdentity(GameExternalIdentityProviders.Steam, "3946950"); + + await repository.UpsertAsync(game.Id, identity); + + Assert.Equal(game.Id, await repository.FindGameIdAsync(identity)); + Assert.Equal(new[] { identity }, await repository.GetForGameAsync(game.Id)); + } + + [Fact] + public async Task SameProviderIdentity_CannotSilentlyMoveToAnotherGame() + { + Directory.CreateDirectory(_directory); + var database = new GameHoursDatabase(Path.Combine(_directory, "gamehours.db")); + await database.InitializeAsync(); + var games = new SqliteGameRepository(database); + var first = new TrackedGame(Guid.NewGuid(), "First"); + var second = new TrackedGame(Guid.NewGuid(), "Second"); + await games.UpsertAsync(first); + await games.UpsertAsync(second); + var repository = new SqliteGameExternalIdentityRepository(database); + var identity = new GameExternalIdentity(GameExternalIdentityProviders.Steam, "570"); + await repository.UpsertAsync(first.Id, identity); + + var exception = await Assert.ThrowsAsync( + () => repository.UpsertAsync(second.Id, identity)); + + Assert.Contains("already linked", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(first.Id, await repository.FindGameIdAsync(identity)); + } + + [Fact] + public async Task ProviderNamesAreNormalizedAndProviderNamespacesStayIndependent() + { + Directory.CreateDirectory(_directory); + var database = new GameHoursDatabase(Path.Combine(_directory, "gamehours.db")); + await database.InitializeAsync(); + var games = new SqliteGameRepository(database); + var steamGame = new TrackedGame(Guid.NewGuid(), "Steam game"); + var gogGame = new TrackedGame(Guid.NewGuid(), "GOG game"); + await games.UpsertAsync(steamGame); + await games.UpsertAsync(gogGame); + var repository = new SqliteGameExternalIdentityRepository(database); + + await repository.UpsertManyAsync(new[] + { + (steamGame.Id, new GameExternalIdentity(" STEAM ", "123")), + (gogGame.Id, new GameExternalIdentity(GameExternalIdentityProviders.Gog, "123")) + }); + + Assert.Equal(steamGame.Id, await repository.FindGameIdAsync(new GameExternalIdentity("steam", "123"))); + Assert.Equal(gogGame.Id, await repository.FindGameIdAsync(new GameExternalIdentity("gog", "123"))); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } +} From 1a1df5b6c091bbee9b0e01038aaa0a01cbc5147d Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:48:22 +0200 Subject: [PATCH 22/52] test: migrate restored backups through library v8 --- tests/GameHours.Tests/GameHoursDataRestoreTests.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/GameHours.Tests/GameHoursDataRestoreTests.cs b/tests/GameHours.Tests/GameHoursDataRestoreTests.cs index c1aa86a..057d59e 100644 --- a/tests/GameHours.Tests/GameHoursDataRestoreTests.cs +++ b/tests/GameHours.Tests/GameHoursDataRestoreTests.cs @@ -113,6 +113,8 @@ INSERT INTO session_activity( FROM session_activity_v5_test; DROP TABLE session_activity_v5_test; CREATE INDEX idx_session_activity_game ON session_activity(game_id, updated_at_utc); + DROP TABLE game_library_preferences; + DROP TABLE game_external_identities; PRAGMA user_version = 4; UPDATE schema_info SET version = 4; """; @@ -135,7 +137,7 @@ INSERT INTO session_activity( await using var version = restored.CreateCommand(); version.CommandText = "PRAGMA user_version;"; - Assert.Equal(7L, Convert.ToInt64(await version.ExecuteScalarAsync())); + Assert.Equal(8L, Convert.ToInt64(await version.ExecuteScalarAsync())); await using var coverageColumn = restored.CreateCommand(); coverageColumn.CommandText = "SELECT COUNT(*) FROM pragma_table_info('achievement_observation_state') WHERE name = 'state_coverage';"; @@ -145,6 +147,14 @@ INSERT INTO session_activity( evidenceTable.CommandText = "SELECT COUNT(*) FROM pragma_table_info('achievement_unlock_evidence');"; Assert.Equal(11L, Convert.ToInt64(await evidenceTable.ExecuteScalarAsync())); + await using var libraryPreferences = restored.CreateCommand(); + libraryPreferences.CommandText = "SELECT COUNT(*) FROM pragma_table_info('game_library_preferences');"; + Assert.Equal(5L, Convert.ToInt64(await libraryPreferences.ExecuteScalarAsync())); + + await using var externalIdentities = restored.CreateCommand(); + externalIdentities.CommandText = "SELECT COUNT(*) FROM pragma_table_info('game_external_identities');"; + Assert.Equal(4L, Convert.ToInt64(await externalIdentities.ExecuteScalarAsync())); + await using var activity = restored.CreateCommand(); activity.CommandText = """ SELECT focused_duration_ms, active_duration_ms, idle_threshold_ms, afk_filter_enabled From a68b0d94ee07a06c9ecdb538aba576c6589efa15 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:48:53 +0200 Subject: [PATCH 23/52] test: cover paused library compatibility --- .../MainWindowLibraryInteractionTests.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/GameHours.Windows.Tests/MainWindowLibraryInteractionTests.cs b/tests/GameHours.Windows.Tests/MainWindowLibraryInteractionTests.cs index 79a0b7f..868a611 100644 --- a/tests/GameHours.Windows.Tests/MainWindowLibraryInteractionTests.cs +++ b/tests/GameHours.Windows.Tests/MainWindowLibraryInteractionTests.cs @@ -108,6 +108,28 @@ public void LibraryScopes_FilterFavoriteCompletionAndRunningIndependently() Assert.False(MainWindow.ShouldShowLibraryGame(game, preferences, LibraryFilterScope.Completed, null, active)); } + [Fact] + public void PausedStatus_HasItsOwnCompatibleScope() + { + var game = new MainWindow.GameRowViewModel(CreateGame("Paused", null, TimeSpan.Zero)); + var preferences = new LibraryGamePreferences( + game.GameId, + CompletionStatus: LibraryCompletionStatus.Paused); + + Assert.True(MainWindow.ShouldShowLibraryGame( + game, + preferences, + LibraryFilterScope.Paused, + null, + Array.Empty())); + Assert.False(MainWindow.ShouldShowLibraryGame( + game, + preferences, + LibraryFilterScope.Playing, + null, + Array.Empty())); + } + [Fact] public void LibrarySearch_ComposesWithScope() { From 66d4d7da27d0a7dcb6e3a17026c23b7509322052 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:49:14 +0200 Subject: [PATCH 24/52] test: roundtrip paused library status --- .../SqliteLibraryGamePreferencesRepositoryTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/GameHours.Tests/SqliteLibraryGamePreferencesRepositoryTests.cs b/tests/GameHours.Tests/SqliteLibraryGamePreferencesRepositoryTests.cs index 6d0dbb0..72ecc03 100644 --- a/tests/GameHours.Tests/SqliteLibraryGamePreferencesRepositoryTests.cs +++ b/tests/GameHours.Tests/SqliteLibraryGamePreferencesRepositoryTests.cs @@ -25,12 +25,12 @@ await repository.SetAsync(new LibraryGamePreferences( game.Id, IsFavorite: true, IsHidden: true, - CompletionStatus: LibraryCompletionStatus.Completed)); + CompletionStatus: LibraryCompletionStatus.Paused)); var loaded = await repository.GetAsync(game.Id); Assert.True(loaded.IsFavorite); Assert.True(loaded.IsHidden); - Assert.Equal(LibraryCompletionStatus.Completed, loaded.CompletionStatus); + Assert.Equal(LibraryCompletionStatus.Paused, loaded.CompletionStatus); var all = await repository.GetAllAsync(); Assert.Equal(loaded, all[game.Id]); From 417036f9c5f087fa44e97eb78ec7d339d08aa6db Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:49:38 +0200 Subject: [PATCH 25/52] docs: align gestor juegos integration boundary --- integration/gestor-juegos/README.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/integration/gestor-juegos/README.md b/integration/gestor-juegos/README.md index fadf170..9ac1c30 100644 --- a/integration/gestor-juegos/README.md +++ b/integration/gestor-juegos/README.md @@ -1,7 +1,19 @@ # Gestor de Juegos integration -This directory is an optional adapter boundary. GameHours itself remains backend-neutral and does not import or depend on the Gestor backend. +This directory is the optional adapter boundary between GameHours and `Ayerdi/gestor-juegos`. GameHours remains fully local-first and must continue working without an account, network connection or Gestor deployment. -The canonical GameHours sync model lives in [`../../docs/SYNC-BOUNDARY.md`](../../docs/SYNC-BOUNDARY.md) and uses GameHours-owned UUIDs. Any Gestor-specific catalogue mapping, field translation, authentication or endpoint behaviour belongs here or in the `gestor-juegos` repository, not in `GameHours.Core` or the neutral sync contracts. +The canonical GameHours sync model lives in [`../../docs/SYNC-BOUNDARY.md`](../../docs/SYNC-BOUNDARY.md) and uses GameHours-owned UUIDs. Gestor catalogue IDs, authentication and endpoint behaviour must not leak into `GameHours.Core` or the neutral sync contracts. -The deferred Gestor wire draft is documented in [`API-CONTRACT-DRAFT.md`](API-CONTRACT-DRAFT.md). Integration work is intentionally paused while GameHours continues maturing as a standalone application. +## Compatibility foundation + +Library 2.0 keeps the two products compatible without coupling them: + +- GameHours keeps its UUID as the authoritative tracking identity; +- provider-scoped identities such as `steam:3946950` can be persisted in `game_external_identities` and are the preferred correlation key for optional catalogue providers; +- a future Gestor adapter may resolve `steam:` against `catalogo_juegos.steam_id` and cache the resulting `catalogo_juego_id`, but that Gestor-local ID never replaces the GameHours UUID; +- `favorito` maps naturally to GameHours `IsFavorite`; +- the shared personal states are `Pendiente`, `Jugando`, `Pausado`, `Completado` and `Abandonado`; +- GameHours `IsHidden` is local presentation state and has no Gestor equivalent, so an external provider must not overwrite it; +- GameHours measured playtime remains authoritative local evidence. Gestor `tiempo_jugado` and Steam snapshots are optional external information and must not rewrite measured sessions. + +The reviewed Gestor field/API mapping and conflict rules live in [`API-CONTRACT-DRAFT.md`](API-CONTRACT-DRAFT.md). The actual network adapter remains deferred: this foundation deliberately adds no remote request, credential or startup dependency. From 22d36ad68a7b1ab124786f99e8fc0ec3fbbf41f2 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:50:10 +0200 Subject: [PATCH 26/52] docs: define gestor juegos compatibility contract --- .../gestor-juegos/API-CONTRACT-DRAFT.md | 82 +++++++++++++++++-- 1 file changed, 73 insertions(+), 9 deletions(-) diff --git a/integration/gestor-juegos/API-CONTRACT-DRAFT.md b/integration/gestor-juegos/API-CONTRACT-DRAFT.md index e67daf2..70f59a8 100644 --- a/integration/gestor-juegos/API-CONTRACT-DRAFT.md +++ b/integration/gestor-juegos/API-CONTRACT-DRAFT.md @@ -1,10 +1,77 @@ -# Gestor de Juegos adapter contract — deferred draft +# Gestor de Juegos adapter contract — compatibility draft -This document belongs to the optional Gestor de Juegos integration, not to the backend-neutral GameHours sync contract. +This document belongs to the optional Gestor de Juegos integration, not to the backend-neutral GameHours sync contract. It was reviewed against the current `Ayerdi/gestor-juegos` schema/API documentation and `main` implementation on 2026-08-31. -GameHours emits its own UUID-based model described in `../../docs/SYNC-BOUNDARY.md`. A future Gestor adapter will be responsible for resolving a GameHours `game_id` to a Gestor `catalogo_juego_id`, authenticating the native device and translating the neutral model into the Gestor API shape. +GameHours remains the tracking authority and emits its UUID-based model described in `../../docs/SYNC-BOUNDARY.md`. A future adapter may enrich or synchronize selected library fields, but the adapter must be removable without changing local tracking behaviour. -Possible Gestor-side payload shape: +## Identity mapping + +Never use a Gestor database primary key as the canonical GameHours identity. + +Preferred matching order: + +1. `steam:` from GameHours `game_external_identities` -> Gestor `catalogo_juegos.steam_id`; +2. `igdb:` -> Gestor `catalogo_juegos.igdb_id` when GameHours has a verified IGDB identity in the future; +3. title matching only as an explicit user-assisted fallback, never as silent authoritative identity. + +After a verified match, an adapter may cache `catalogo_juego_id` as a Gestor-specific link. That cached link is replaceable integration state; the GameHours UUID and measured history remain valid if the Gestor is unavailable or rebuilt. + +Provider IDs are namespaced. `steam:123` and `gog:123` are different identities. One provider identity must not silently move between two GameHours games. + +## Library state mapping + +The current common personal-state subset is: + +| GameHours | Gestor `mis_juegos.estado` | +| --- | --- | +| `Backlog` | `Pendiente` | +| `Playing` | `Jugando` | +| `Paused` | `Pausado` | +| `Completed` | `Completado` | +| `Abandoned` | `Abandonado` | +| `Unspecified` | no imported state | + +Gestor also supports states such as `Deseado`, `En Espera` and `Wishlist`. GameHours must not coerce those into a different completion state. Until GameHours intentionally adds an equivalent concept, an adapter should preserve them as source-specific information or leave local completion status unchanged. + +`mis_juegos.favorito` maps to GameHours `IsFavorite`. + +GameHours `IsHidden` is local-only presentation state. There is no equivalent field in the reviewed Gestor schema, so remote data must never clear or set it. + +## Field authority + +| Information | Authority / rule | +| --- | --- | +| GameHours UUID | GameHours only | +| measured sessions and focused/active telemetry | GameHours only; never overwritten by Gestor | +| reconstructed SRUM history | GameHours evidence; never converted into exact Gestor/Steam truth | +| `favorito` / completion status | optional library sync; conflict policy must be explicit before bidirectional writes are enabled | +| `tiempo_jugado` | external/personal Gestor information; may be displayed or imported as separately labelled evidence, not written over measured sessions | +| `horas_steam_snapshot`, Steam achievement snapshots | external snapshots only | +| cover, developer, publisher, release date, genres and similar catalogue metadata | optional enrichment with provider provenance/cache | +| hidden/archive | GameHours local only | + +A first integration should therefore be read-only enrichment/import. Bidirectional preference writes should be a later opt-in feature with a visible conflict policy rather than last-write-wins by accident. + +## Current Gestor surfaces relevant to a future adapter + +The reviewed Gestor exposes a global `catalogo_juegos` model and a separate `mis_juegos` user relationship. This separation matches GameHours' decision to keep external catalogue identity separate from local user preferences. + +Useful current endpoints include: + +- `GET /mis-juegos` for the authenticated user's personal library; +- `GET /mis-juegos/detalle/?fast=1` for DB-only detail; +- `GET /mis-juegos/detalle//enrich` for slower external enrichment; +- `GET /catalogo/buscar-o-importar?nombre=...` for catalogue lookup/import. + +The exact response shape remains owned by the Gestor repository and must be translated inside the adapter rather than copied into `GameHours.Core`. + +## Authentication + +The desktop client must use a dedicated native device/account credential flow. It must not spoof browser-oriented Authentik identity headers. No credential or Gestor URL is required for GameHours startup or tracking. + +## Deferred playtime upload shape + +If session upload is resumed later, the adapter may translate a neutral GameHours session after resolving the local UUID to a Gestor catalogue entry, for example: ```json { @@ -18,11 +85,8 @@ Possible Gestor-side payload shape: "capture_method": "reconciliation", "confidence": "high" } - ], - "historical": [] + ] } ``` -This shape is intentionally deferred and may change when the Gestor integration is resumed. It must not leak back into `GameHours.Core` or the neutral `GameHours.Sync` contracts. - -The native client must use a dedicated device/account credential flow rather than spoofable browser-oriented Authentik headers. +This wire shape is still deferred and may change. It must not leak into the backend-neutral GameHours sync contracts. From 2ed7a07e8ee3c0e5e58ce3559526980c3d125e76 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:53:02 +0200 Subject: [PATCH 27/52] fix: keep generic external ids exact --- src/GameHours.Storage/Sqlite/GameHoursDatabase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GameHours.Storage/Sqlite/GameHoursDatabase.cs b/src/GameHours.Storage/Sqlite/GameHoursDatabase.cs index 14a4509..8271f80 100644 --- a/src/GameHours.Storage/Sqlite/GameHoursDatabase.cs +++ b/src/GameHours.Storage/Sqlite/GameHoursDatabase.cs @@ -341,7 +341,7 @@ updated_at_utc TEXT NOT NULL CREATE TABLE IF NOT EXISTS game_external_identities ( game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, provider TEXT NOT NULL COLLATE NOCASE CHECK (length(trim(provider)) > 0), - external_id TEXT NOT NULL COLLATE NOCASE CHECK (length(trim(external_id)) > 0), + external_id TEXT NOT NULL CHECK (length(trim(external_id)) > 0), updated_at_utc TEXT NOT NULL, PRIMARY KEY (game_id, provider, external_id), UNIQUE (provider, external_id) From 066f16bff9ecc26c137a36c3d503c8caf61a210e Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:53:20 +0200 Subject: [PATCH 28/52] fix: compare external identity values exactly --- .../Sqlite/SqliteGameExternalIdentityRepository.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/GameHours.Storage/Sqlite/SqliteGameExternalIdentityRepository.cs b/src/GameHours.Storage/Sqlite/SqliteGameExternalIdentityRepository.cs index fa866b2..ba23f61 100644 --- a/src/GameHours.Storage/Sqlite/SqliteGameExternalIdentityRepository.cs +++ b/src/GameHours.Storage/Sqlite/SqliteGameExternalIdentityRepository.cs @@ -65,7 +65,7 @@ public async Task> GetForGameAsync( SELECT provider, external_id FROM game_external_identities WHERE game_id = $game_id - ORDER BY provider COLLATE NOCASE, external_id COLLATE NOCASE; + ORDER BY provider COLLATE NOCASE, external_id; """; command.Parameters.AddWithValue("$game_id", gameId.ToString("D")); @@ -89,7 +89,7 @@ FROM game_external_identities SELECT game_id FROM game_external_identities WHERE provider = $provider COLLATE NOCASE - AND external_id = $external_id COLLATE NOCASE + AND external_id = $external_id LIMIT 1; """; command.Parameters.AddWithValue("$provider", identity.Provider); @@ -112,7 +112,7 @@ private static async Task UpsertCoreAsync( SELECT game_id FROM game_external_identities WHERE provider = $provider COLLATE NOCASE - AND external_id = $external_id COLLATE NOCASE + AND external_id = $external_id LIMIT 1; """; ownership.Parameters.AddWithValue("$provider", identity.Provider); From 05aa1af381d37d921b8a56142aac52582c907bb5 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:53:43 +0200 Subject: [PATCH 29/52] test: keep external id semantics provider-defined --- ...liteGameExternalIdentityRepositoryTests.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/GameHours.Tests/SqliteGameExternalIdentityRepositoryTests.cs b/tests/GameHours.Tests/SqliteGameExternalIdentityRepositoryTests.cs index 8f57e90..7b47618 100644 --- a/tests/GameHours.Tests/SqliteGameExternalIdentityRepositoryTests.cs +++ b/tests/GameHours.Tests/SqliteGameExternalIdentityRepositoryTests.cs @@ -72,6 +72,29 @@ await repository.UpsertManyAsync(new[] Assert.Equal(gogGame.Id, await repository.FindGameIdAsync(new GameExternalIdentity("gog", "123"))); } + [Fact] + public async Task ExternalIdComparison_RemainsProviderDefinedAndCaseSensitive() + { + Directory.CreateDirectory(_directory); + var database = new GameHoursDatabase(Path.Combine(_directory, "gamehours.db")); + await database.InitializeAsync(); + var games = new SqliteGameRepository(database); + var upper = new TrackedGame(Guid.NewGuid(), "Upper custom id"); + var lower = new TrackedGame(Guid.NewGuid(), "Lower custom id"); + await games.UpsertAsync(upper); + await games.UpsertAsync(lower); + var repository = new SqliteGameExternalIdentityRepository(database); + + await repository.UpsertManyAsync(new[] + { + (upper.Id, new GameExternalIdentity("custom", "ABC")), + (lower.Id, new GameExternalIdentity("CUSTOM", "abc")) + }); + + Assert.Equal(upper.Id, await repository.FindGameIdAsync(new GameExternalIdentity("custom", "ABC"))); + Assert.Equal(lower.Id, await repository.FindGameIdAsync(new GameExternalIdentity("custom", "abc"))); + } + public void Dispose() { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); From 7ae7dacb9ff917a66b5c9796e27a5b59571c9659 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:53:55 +0200 Subject: [PATCH 30/52] test: map discovered games to external identities --- .../GameExternalIdentityTests.cs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/GameHours.Tests/GameExternalIdentityTests.cs diff --git a/tests/GameHours.Tests/GameExternalIdentityTests.cs b/tests/GameHours.Tests/GameExternalIdentityTests.cs new file mode 100644 index 0000000..7fa2123 --- /dev/null +++ b/tests/GameHours.Tests/GameExternalIdentityTests.cs @@ -0,0 +1,46 @@ +using GameHours.Core.Domain; + +namespace GameHours.Tests; + +public sealed class GameExternalIdentityTests +{ + [Theory] + [InlineData(GameDiscoverySource.Steam, "3946950", GameExternalIdentityProviders.Steam)] + [InlineData(GameDiscoverySource.Epic, "epic-catalog-offer", GameExternalIdentityProviders.Epic)] + [InlineData(GameDiscoverySource.Gog, "1207659000", GameExternalIdentityProviders.Gog)] + public void DiscoveredCatalogueGame_MapsToProviderScopedIdentity( + GameDiscoverySource source, + string externalId, + string expectedProvider) + { + var game = new DiscoveredGame( + Guid.NewGuid(), + "External identity", + source, + externalId, + Path.Combine(Path.GetTempPath(), "gamehours-identity-test"), + launchExecutable: null, + confidence: 1.0); + + var identity = GameExternalIdentityProviders.FromDiscoveredGame(game); + + Assert.NotNull(identity); + Assert.Equal(expectedProvider, identity.Provider); + Assert.Equal(externalId, identity.ExternalId); + } + + [Fact] + public void LooseProcess_DoesNotInventAnExternalCatalogueIdentity() + { + var game = new DiscoveredGame( + Guid.NewGuid(), + "Loose game", + GameDiscoverySource.LooseProcess, + "local-placeholder", + Path.Combine(Path.GetTempPath(), "gamehours-loose-identity-test"), + launchExecutable: null, + confidence: 0.8); + + Assert.Null(GameExternalIdentityProviders.FromDiscoveredGame(game)); + } +} From cd9467a489b14c33f1c9d23757437194d476b28c Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:54:19 +0200 Subject: [PATCH 31/52] docs: clarify gestor completion semantics --- integration/gestor-juegos/API-CONTRACT-DRAFT.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/integration/gestor-juegos/API-CONTRACT-DRAFT.md b/integration/gestor-juegos/API-CONTRACT-DRAFT.md index 70f59a8..955251f 100644 --- a/integration/gestor-juegos/API-CONTRACT-DRAFT.md +++ b/integration/gestor-juegos/API-CONTRACT-DRAFT.md @@ -16,7 +16,7 @@ Preferred matching order: After a verified match, an adapter may cache `catalogo_juego_id` as a Gestor-specific link. That cached link is replaceable integration state; the GameHours UUID and measured history remain valid if the Gestor is unavailable or rebuilt. -Provider IDs are namespaced. `steam:123` and `gog:123` are different identities. One provider identity must not silently move between two GameHours games. +Provider IDs are namespaced. `steam:123` and `gog:123` are different identities. Provider names are normalized by GameHours, while external identity values remain exact so each provider adapter owns any provider-specific normalization. One provider identity must not silently move between two GameHours games. ## Library state mapping @@ -35,6 +35,8 @@ Gestor also supports states such as `Deseado`, `En Espera` and `Wishlist`. GameH `mis_juegos.favorito` maps to GameHours `IsFavorite`. +Gestor `completado_100` is a separate flag and must **not** be translated into `LibraryCompletionStatus.Completed`; GameHours completion status maps only from `mis_juegos.estado`. Achievement completion and “finished the game” are deliberately different concepts in GameHours as well. + GameHours `IsHidden` is local-only presentation state. There is no equivalent field in the reviewed Gestor schema, so remote data must never clear or set it. ## Field authority From a08bd168385c68a77a81941d2daab35cddf055ab Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:31:09 +0200 Subject: [PATCH 32/52] fix: open library context menu through WPF hook --- .../MainWindow.LibraryInteraction.cs | 55 ++++++++++++++----- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs b/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs index c2f85ca..61859ab 100644 --- a/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs +++ b/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs @@ -18,6 +18,7 @@ public partial class MainWindow { private bool _libraryViewConfigured; private bool _activeGameCursor; + private bool _openingLibraryContextMenu; private readonly Dictionary _libraryPreferences = new(); private readonly SemaphoreSlim _libraryPreferenceWriteGate = new(1, 1); private LibraryToolbar? _libraryToolbar; @@ -85,34 +86,39 @@ protected override void OnPreviewMouseLeftButtonUp(MouseButtonEventArgs e) e.Handled = true; } - protected override async void OnPreviewMouseRightButtonUp(MouseButtonEventArgs e) + protected override void OnContextMenuOpening(ContextMenuEventArgs e) { - base.OnPreviewMouseRightButtonUp(e); - if (e.Handled) + base.OnContextMenuOpening(e); + if (e.Handled || _openingLibraryContextMenu) { return; } - var game = FindDataContext(e.OriginalSource as DependencyObject); - if (game is null) + var row = FindDataContextElement(e.OriginalSource as DependencyObject); + if (row?.DataContext is not GameRowViewModel game) { return; } - if (_libraryPreferencesLoadTask is not null) + // ContextMenuOpening is the native WPF hook for this interaction. The library rows do not + // carry a permanent menu because its labels/checkmarks depend on current persisted state, + // so replace the null menu here and force this first opening after suppressing WPF's + // original attempt. The guard prevents IsOpen from re-entering this routed event. + e.Handled = true; + var menu = BuildLibraryContextMenu(game); + menu.PlacementTarget = row; + menu.Placement = PlacementMode.MousePoint; + row.ContextMenu = menu; + + try { - await _libraryPreferencesLoadTask; + _openingLibraryContextMenu = true; + menu.IsOpen = true; } - - if (!IsLoaded) + finally { - return; + _openingLibraryContextMenu = false; } - - var menu = BuildLibraryContextMenu(game); - menu.Placement = PlacementMode.MousePoint; - menu.IsOpen = true; - e.Handled = true; } protected override void OnPreviewMouseMove(System.Windows.Input.MouseEventArgs e) @@ -515,6 +521,25 @@ private static int CompareLibraryGames( : null; } + private static FrameworkElement? FindDataContextElement(DependencyObject? source) + where T : class + { + for (var current = source; current is not null; current = GetParent(current)) + { + if (current is FrameworkElement { DataContext: T } element) + { + return element; + } + + if (current is FrameworkContentElement { DataContext: T }) + { + continue; + } + } + + return null; + } + private static T? FindDataContext(DependencyObject? source) where T : class { From 0f34d97df78af9ebf72b1dcbb74f8b6c69308871 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:34:40 +0200 Subject: [PATCH 33/52] refactor: simplify library context menu target lookup --- src/GameHours.Desktop/MainWindow.LibraryInteraction.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs b/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs index 61859ab..c4fc474 100644 --- a/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs +++ b/src/GameHours.Desktop/MainWindow.LibraryInteraction.cs @@ -530,11 +530,6 @@ private static int CompareLibraryGames( { return element; } - - if (current is FrameworkContentElement { DataContext: T }) - { - continue; - } } return null; From 16169a7df4132eb691a365e770367ca4d4aae64e Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:50:59 +0200 Subject: [PATCH 34/52] fix: theme combo box popup for dark UI --- src/GameHours.Desktop/App.xaml | 152 +++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/src/GameHours.Desktop/App.xaml b/src/GameHours.Desktop/App.xaml index 796d13e..f6efd2d 100644 --- a/src/GameHours.Desktop/App.xaml +++ b/src/GameHours.Desktop/App.xaml @@ -45,6 +45,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +