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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 108 additions & 1 deletion src/MUI.Catalog/Facets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,103 @@ public static class FacetKeys
public const string Family = "family";

public const string Genre = "genre";

/// <summary>
/// What order the listing comes back in. A filter parameter by spelling and by plumbing, so the
/// panel, the page and the read API cannot grow two words for one question.
/// </summary>
public const string Sort = "sort";
}

/// <summary>
/// The orders the catalogue can be read in.
/// </summary>
/// <remarks>
/// <para>
/// <b>Every one of these sorts on a fact already on the row.</b> There is no "busiest" here and the
/// word is deliberately not used: <c>/rankings</c> means something specific by it — a median over a
/// window with a sample floor under it — and a sort that reads one instantaneous count would be a
/// cruder question wearing the same name. This is "players on now", which is exactly what it orders
/// by and exactly as much as it claims.
/// </para>
/// <para>
/// <b>There is no "recently listed".</b> The only date we have for that is <c>game.first_seen_at</c>,
/// which is when <em>our crawler</em> first reached a game — a picture of where the frontier has got
/// to, not of anything happening in the hobby (the same reasoning that keeps it off the adoption
/// curves, see <c>EcosystemDashboard</c>). Sorted to the top of the catalogue it would read as "new
/// games", which is a claim we would be making out of our own schedule. The <em>newly discovered</em>
/// feed publishes the same dates with the framing that makes them honest, and that is where it stays.
/// </para>
/// </remarks>
public enum GameSort
{
/// <summary>
/// Alphabetical, and the default.
/// </summary>
/// <remarks>
/// The default is the one order that ranks nobody. Every other sort in this enum puts some games
/// above others on a measurement, and a listing that arrives pre-ranked is making an editorial
/// claim the reader never asked for — the same objection this project has to star ratings, one
/// step removed. Sorting is a thing a reader chooses.
/// </remarks>
Name,

/// <summary>Most players counted on right now, first.</summary>
Players,

/// <summary>Most recently reached first.</summary>
Reached,
}

/// <summary>
/// The listing's order, and what it does with the games a sort cannot rank.
/// </summary>
/// <remarks>
/// <para>
/// <b>An unknown is never a zero and never sorts as one.</b> Most of this catalogue answers with
/// nothing we can count — we got in and the <c>WHO</c> was past our parser, or the game published no
/// <c>PLAYERS</c> — and <c>null</c> ordered as <c>0</c> would pile every one of those at the bottom
/// of "players on now" indistinguishably from the games we measured and found empty. That is the
/// central claim of this project made backwards, on the page it is most likely to be read off.
/// </para>
/// <para>
/// So the games a sort can rank come first, in order, and the ones it cannot follow as a group in
/// the default order. <see cref="IsUnranked"/> is the same question the surfaces ask to know where to
/// draw the line and what to call the group, so the ordering and the label cannot disagree about
/// which games are in it.
/// </para>
/// </remarks>
public static class GameSorting
{
/// <summary>Whether this sort has nothing to rank a game by — never "whether it is zero".</summary>
public static bool IsUnranked(GameSummary game, GameSort sort)
{
ArgumentNullException.ThrowIfNull(game);

return sort switch
{
GameSort.Players => game.PlayersNow is null,
GameSort.Reached => game.LastReachableAt is null,
_ => false,
};
}

public static IReadOnlyList<GameSummary> Apply(IEnumerable<GameSummary> games, GameSort sort)
{
ArgumentNullException.ThrowIfNull(games);

// Ranked before unranked, always — then the sort's own key, then the name, so the order is
// total and a listing does not shuffle between two identical requests.
var ordered = games
.OrderBy(g => IsUnranked(g, sort) ? 1 : 0)
.ThenByDescending(g => sort is GameSort.Players ? g.PlayersNow ?? 0 : 0)
.ThenByDescending(g => sort is GameSort.Reached
? g.LastReachableAt ?? DateTimeOffset.MinValue
: DateTimeOffset.MinValue)
.ThenBy(g => g.Name, StringComparer.OrdinalIgnoreCase);

return [.. ordered];
}
}

/// <summary>
Expand Down Expand Up @@ -377,7 +474,10 @@ public static GameListing Search(IReadOnlyList<GameFacetRow> rows, GameFilter fi

groups.AddRange(Presence(results, filter));

return new GameListing([.. results.Select(r => r.Summary)], groups);
// Ordered after the counting, never before it. Every facet count is taken over a set, and a
// set has no order — so sorting here cannot move a number, which is what lets the panel go on
// promising exactly what a click returns whichever way the list is arranged.
return new GameListing(GameSorting.Apply(results.Select(r => r.Summary), filter.Sort), groups);
}

/// <summary>
Expand Down Expand Up @@ -656,6 +756,9 @@ public static class FacetTokens
public static IReadOnlyList<string> LastSeenBands { get; } =
[.. Enum.GetValues<LastSeenBand>().Select(Of)];

public static IReadOnlyList<string> Sorts { get; } =
[.. Enum.GetValues<GameSort>().Select(Of)];

/// <summary>The three windows that nest, widest last.</summary>
private static readonly string?[] Nested =
[Of(LastSeenBand.Day), Of(LastSeenBand.Week), Of(LastSeenBand.Month)];
Expand Down Expand Up @@ -684,10 +787,14 @@ public static class FacetTokens

public static string Of(LastSeenBand band) => Camel(band.ToString());

public static string Of(GameSort sort) => Camel(sort.ToString());

public static bool TryBand(string? text, out ActivityBand band) => TryRead(text, out band);

public static bool TryLastSeen(string? text, out LastSeenBand band) => TryRead(text, out band);

public static bool TrySort(string? text, out GameSort sort) => TryRead(text, out sort);

/// <summary>
/// Reads one of the derived vocabularies, forgivingly about separators and strictly about
/// everything else.
Expand Down
11 changes: 11 additions & 0 deletions src/MUI.Catalog/Views.cs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,17 @@ public sealed record GameFilter
/// </para>
/// </remarks>
public FacetChoice? CodebaseFamily { get; init; }

/// <summary>
/// What order the answer comes back in.
/// </summary>
/// <remarks>
/// A question about presentation, and still part of the filter, because the URL is the whole of
/// this page's state: a sorted listing has to be linkable exactly as a filtered one is, and the
/// read API has to answer the same question the page asked. <see cref="GameSort.Name"/> is the
/// default and is the one order that ranks nobody.
/// </remarks>
public GameSort Sort { get; init; } = GameSort.Name;
}

/// <summary>
Expand Down
6 changes: 4 additions & 2 deletions src/MUI.Web/Api/ApiModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ public sealed record FilterView(
string? Family,
string? Genre,
string? Language,
string? CodebaseFamily)
string? CodebaseFamily,
GameSort Sort)
{
public static FilterView Of(GameFilter filter)
{
Expand All @@ -213,7 +214,8 @@ public static FilterView Of(GameFilter filter)
filter.Family?.Token,
filter.Genre?.Token,
filter.Language?.Token,
filter.CodebaseFamily?.Token);
filter.CodebaseFamily?.Token,
filter.Sort);
}
}

Expand Down
31 changes: 30 additions & 1 deletion src/MUI.Web/Api/GameFilterBinding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ private static bool TryRead(
{
result = null!;

if (!TryBand(read, out var band, out error) || !TryLastSeen(read, out var seen, out error))
if (!TryBand(read, out var band, out error)
|| !TryLastSeen(read, out var seen, out error)
|| !TrySort(read, out var sort, out error))
{
return false;
}
Expand Down Expand Up @@ -92,6 +94,7 @@ private static bool TryRead(
CodebaseFamily = string.IsNullOrWhiteSpace(codebaseFamily)
? null
: FacetChoice.Parse(codebaseFamily.Trim()),
Sort = sort,
};

result = new GameQuery(
Expand Down Expand Up @@ -175,6 +178,32 @@ private static bool TryLastSeen(Func<string, StringValues> read, out LastSeenBan
return true;
}

/// <summary>
/// The listing's order. Refused rather than ignored, like every other unreadable facet: a
/// consumer who asked for <c>?sort=busiest</c> and silently got the alphabet would read the first
/// name on the page as the busiest game on the site.
/// </summary>
private static bool TrySort(Func<string, StringValues> read, out GameSort sort, out string? error)
{
sort = GameSort.Name;
error = null;
var text = read(FacetKeys.Sort).ToString();

if (string.IsNullOrWhiteSpace(text))
{
return true;
}

if (!FacetTokens.TrySort(text, out var parsed))
{
error = $"'{text}' is not a sort order. Accepted: {string.Join(", ", FacetTokens.Sorts)}.";
return false;
}

sort = parsed;
return true;
}

private static int Bounded(string? value, int fallback, int min, int max)
{
if (!int.TryParse(value, out var parsed))
Expand Down
134 changes: 134 additions & 0 deletions src/MUI.Web/Components/ActiveFilters.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
using MUI.Catalog;

namespace MUI.Web.Components;

/// <summary>One thing the query is currently asking for, and the URL that stops asking it.</summary>
/// <param name="Facet">Which question — <c>codebase</c>, <c>search</c>.</param>
/// <param name="Value">What it is asking of that question, polarity included.</param>
/// <param name="RemoveHref">The same listing with this one selection dropped and every other kept.</param>
public sealed record ActiveFilter(string Facet, string Value, string RemoveHref);

/// <summary>
/// Everything the current query asks for, read back out of the facets it was applied to.
/// </summary>
/// <remarks>
/// <para>
/// The panel is not the only place the query is visible: what it is asking for is repeated above the
/// results as a row of chips, each of which is a link that removes itself. That is what makes the
/// third state legible — <c>?codebase=!Evennia</c> renders as <em>codebase · anything but
/// Evennia</em>, where a <c>&lt;select&gt;</c> scrolled to an option in its second
/// <c>&lt;optgroup&gt;</c> shows nothing at all until you open it — and it is the only affordance on
/// the page that can undo one filter without disturbing the rest.
/// </para>
/// <para>
/// Built from <see cref="FacetGroup"/> rather than from <see cref="GameFilter"/>, deliberately. The
/// facets already carry each value's <see cref="FacetState"/>, computed by the same pass that
/// produced the listing, so a chip cannot claim a selection the query did not apply — and a facet
/// added to the catalogue gets a chip without anything here being told about it.
/// </para>
/// </remarks>
public static class ActiveFilters
{
public static IReadOnlyList<ActiveFilter> For(
IReadOnlyList<FacetGroup> facets,
GameFilter filter,
string? query)
{
ArgumentNullException.ThrowIfNull(facets);
ArgumentNullException.ThrowIfNull(filter);

var chips = new List<ActiveFilter>();

if (!string.IsNullOrWhiteSpace(filter.Text))
{
chips.Add(new ActiveFilter(
"search", filter.Text.Trim(), Href(ListingLinks.With(query, FacetKeys.Text, null))));
}

if (filter.CodebaseFamily is { Exclude: false, Value: { } family })
{
chips.Add(new ActiveFilter(
"codebase", family, Href(ListingLinks.With(query, FacetKeys.CodebaseFamily, null))));
}

var drawn = new HashSet<string>(StringComparer.Ordinal);

foreach (var group in facets)
{
foreach (var value in group.Values.Where(v => v.State is not FacetState.Unselected))
{
drawn.Add(group.Key);

chips.Add(new ActiveFilter(
FacetWords.Group(group.Key),
value.State is FacetState.Excluded
? FacetWords.Excluded(group.Key, value)
: FacetWords.Value(group.Key, value),

// A choice facet holds one selection, so removing it drops the parameter; a
// presence facet holds several in one repeatable, comma-separated parameter, so
// removing one has to leave the others behind.
Href(group.Kind is FacetKind.Choice
? ListingLinks.With(query, group.Key, null)
: ListingLinks.Without(query, group.Key, value.Token))));
}
}

// A selection the panel is not offering back.
//
// An open-ended facet's values come from what is in the results, so a selection that matches
// nothing left in them has no value to hang a chip on — ?codebase=!Evennia beside a search
// that returns no Evennia game at all is the ordinary case, not a corner. Left there, the one
// affordance for undoing a filter would go missing exactly when the filter is doing the most
// and the reader can see the least of why.
foreach (var (key, choice) in Open(filter))
{
if (choice is null || !drawn.Add(key))
{
continue;
}

var stand = new FacetValue(
choice.Value ?? FacetChoice.UnknownToken,
Count: 0,
IsSelected: true,
IsUnknown: choice.IsUnknown,
IsExcluded: choice.Exclude);

chips.Add(new ActiveFilter(
FacetWords.Group(key),
choice.Exclude ? FacetWords.Excluded(key, stand) : FacetWords.Value(key, stand),
Href(ListingLinks.With(query, key, null))));
}

// Last, because it widens the answer rather than narrowing it and reads oddly among the
// things that narrow it — but present, because it is a thing the URL is asking for and a
// reader who cannot see it asked has no way to stop asking.
if (filter.IncludeArchived)
{
chips.Add(new ActiveFilter(
"archived", "included", Href(ListingLinks.With(query, FacetKeys.Archived, null))));
}

return chips;
}

/// <summary>
/// The facets whose vocabulary comes from the data rather than from an enum, which are the ones
/// that can be asked for a value the current results do not contain.
/// </summary>
/// <remarks>
/// The two derived facets are not here: their values are a fixed vocabulary and a selected one
/// stays in the panel at a count of zero, so it always has a chip already.
/// </remarks>
private static IEnumerable<(string Key, FacetChoice? Choice)> Open(GameFilter filter) =>
[
(FacetKeys.Charset, filter.Charset),
(FacetKeys.Codebase, filter.Codebase),
(FacetKeys.Family, filter.Family),
(FacetKeys.Genre, filter.Genre),
(FacetKeys.Language, filter.Language),
];

private static string Href(string queryString) => "/games" + queryString;
}
Loading