diff --git a/common/components/filters.py b/common/components/filters.py index dc8784f..e8527ea 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -65,9 +65,19 @@ def _filter_parse(filter_json: str) -> dict: return {} -def _extract_labeled(items: list[dict]) -> list[LabeledOption]: - """Convert a list of ``{id, label}`` dicts to ``(value, label)`` pairs.""" - return [(str(item["id"]), str(item["label"])) for item in items] +def _extract_labeled(items: list) -> list[LabeledOption]: + """Convert filter values to ``(value, label)`` pairs. + + UI-built filters carry ``{id, label}`` dicts; programmatically-built ones + (e.g. stats_links) carry bare ids/choices. A bare value uses itself as its + own label so the bar renders any valid filter instead of crashing.""" + pairs: list[LabeledOption] = [] + for item in items: + if isinstance(item, dict): + pairs.append((str(item["id"]), str(item["label"]))) + else: + pairs.append((str(item), str(item))) + return pairs def _filter_get_choice(existing: dict, field: str) -> FilterChoice: diff --git a/games/views/stats_content.py b/games/views/stats_content.py index dd84aec..8a59c83 100644 --- a/games/views/stats_content.py +++ b/games/views/stats_content.py @@ -14,7 +14,9 @@ from common.components import ( A, Div, Element, + Fragment, GameLink, + Icon, Node, Safe, Td, @@ -23,11 +25,46 @@ from common.components import ( YearPicker, ) from common.time import durationformat, format_duration +from games.filters import filter_url +from games.views import stats_links _CELL = "px-2 sm:px-4 md:px-6 md:py-2" _CELL_MONO = f"{_CELL} font-mono" _NAME_TH = f"{_CELL} purchase-name truncate max-w-20char" +# Stats lists are previews: capped to this many rows, with a "View all" link to +# the full filtered list (#65). +_LIST_CAP = 5 + + +def _session_link(game_id, year) -> Node: + """Small affordance linking a game row to its (year-scoped) session list. + Sits next to the existing GameLink (which goes to the game detail page).""" + return A( + href=filter_url(stats_links.sessions_for_game(game_id, year)), + class_="ml-1 inline-block align-middle hover:text-heading", + title="View sessions", + )[Icon("play")] + + +def _count_link(value, url: str) -> Node: + return A(href=url, class_="hover:underline decoration-dotted")[str(value)] + + +def _view_all_row(count: int, url: str) -> Node: + return _tr( + [ + Td( + attributes=[("class", _CELL), ("colspan", "3")], + children=[ + A(href=url, class_="underline decoration-dotted")[ + f"View all ({count}) →" + ] + ], + ) + ] + ) + def _td(children, cls: str = _CELL_MONO) -> Node: if not isinstance(children, list): @@ -119,14 +156,28 @@ def _playtime_table(ctx) -> Node: year = ctx.get("year") rows = [ _kv("Hours", ctx.get("total_hours")), - _kv("Sessions", ctx.get("total_sessions")), + _kv( + "Sessions", + _count_link( + ctx.get("total_sessions"), + filter_url(stats_links.all_sessions(year)), + ), + ), _kv( "Days", f"{ctx.get('unique_days')} ({ctx.get('unique_days_percent')}%)", ), ] if ctx.get("total_games"): - rows.append(_kv("Games", ctx.get("total_games"))) + rows.append( + _kv( + "Games", + _count_link( + ctx.get("total_games"), + filter_url(stats_links.games_played(year)), + ), + ) + ) rows.append(_kv(f"Games ({year})", ctx.get("total_year_games"))) if ctx.get("all_finished_this_year_count"): rows.append(_kv("Finished", ctx.get("all_finished_this_year_count"))) @@ -138,7 +189,15 @@ def _playtime_table(ctx) -> Node: return _tr( [ _td(label, _CELL), - _td([str(value), " (", GameLink(game.id, game.name), ")"]), + _td( + [ + str(value), + " (", + GameLink(game.id, game.name), + ")", + _session_link(game.id, year), + ] + ), ] ) @@ -171,6 +230,7 @@ def _playtime_table(ctx) -> Node: [ GameLink(first_game.id, first_game.name), f" ({ctx.get('first_play_date')})", + _session_link(first_game.id, year), ] ), ] @@ -186,6 +246,7 @@ def _playtime_table(ctx) -> Node: [ GameLink(last_game.id, last_game.name), f" ({ctx.get('last_play_date')})", + _session_link(last_game.id, year), ] ), ] @@ -195,23 +256,45 @@ def _playtime_table(ctx) -> Node: def _purchases_table(ctx) -> Node: + year = ctx.get("year") rows = [ - _kv("Total", ctx.get("all_purchased_this_year_count")), + _kv( + "Total", + _count_link( + ctx.get("all_purchased_this_year_count"), + filter_url(stats_links.purchases_total(year)), + ), + ), _kv( "Refunded", - f"{ctx.get('all_purchased_refunded_this_year_count')} " - f"({ctx.get('refunded_percent')}%)", + _count_link( + f"{ctx.get('all_purchased_refunded_this_year_count')} " + f"({ctx.get('refunded_percent')}%)", + filter_url(stats_links.purchases_refunded(year)), + ), ), _kv( "Dropped", - f"{ctx.get('dropped_count')} ({ctx.get('dropped_percentage')}%)", + _count_link( + f"{ctx.get('dropped_count')} ({ctx.get('dropped_percentage')}%)", + filter_url(stats_links.purchases_dropped(year)), + ), ), _kv( "Unfinished", - f"{ctx.get('purchased_unfinished_count')} " - f"({ctx.get('unfinished_purchases_percent')}%)", + _count_link( + f"{ctx.get('purchased_unfinished_count')} " + f"({ctx.get('unfinished_purchases_percent')}%)", + filter_url(stats_links.purchases_unfinished(year)), + ), + ), + _kv( + "Backlog Decrease", + _count_link( + ctx.get("backlog_decrease_count"), + filter_url(stats_links.purchases_backlog_decrease(year)), + ), ), - _kv("Backlog Decrease", ctx.get("backlog_decrease_count")), _kv( f"Spendings ({ctx.get('total_spent_currency')})", f"{floatformat(ctx.get('total_spent'))} " @@ -221,34 +304,45 @@ def _purchases_table(ctx) -> Node: return _table(rows) -def _two_col_table(header: str, items, name_key, value_fn) -> Node: +def _two_col_table(header: str, items, name_key, value_fn, view_all_url=None) -> Node: thead = Element( "thead", children=[_tr([_th(header), _th("Playtime")])], ) - rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in items] + items = list(items) + display = items[:_LIST_CAP] if view_all_url else items + rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in display] + if view_all_url and len(items) > _LIST_CAP: + rows.append(_view_all_row(len(items), view_all_url)) return _table(rows, thead) -def _finished_table(purchases) -> Node: +def _finished_table(purchases, view_all_url=None, total=None) -> Node: thead = Element( "thead", children=[_tr([_th("Name", _NAME_TH), _th("Date")])], ) + purchases = list(purchases) + display = purchases[:_LIST_CAP] if view_all_url else purchases rows = [ _tr([_td(_purchase_name(p)), _td(date_filter(p.date_finished, "d/m/Y"))]) - for p in purchases + for p in display ] + total = total if total is not None else len(purchases) + if view_all_url and total > _LIST_CAP: + rows.append(_view_all_row(total, view_all_url)) return _table(rows, thead) -def _priced_table(purchases, currency) -> Node: +def _priced_table(purchases, currency, view_all_url=None, total=None) -> Node: thead = Element( "thead", children=[ _tr([_th("Name", _NAME_TH), _th(f"Price ({currency})"), _th("Date")]) ], ) + purchases = list(purchases) + display = purchases[:_LIST_CAP] if view_all_url else purchases rows = [ _tr( [ @@ -257,8 +351,11 @@ def _priced_table(purchases, currency) -> Node: _td(date_filter(p.date_purchased, "d/m/Y")), ] ) - for p in purchases + for p in display ] + total = total if total is not None else len(purchases) + if view_all_url and total > _LIST_CAP: + rows.append(_view_all_row(total, view_all_url)) return _table(rows, thead) @@ -281,7 +378,21 @@ def stats_content(ctx: dict) -> Node: if months: sections.append(_h1("Playtime per month")) month_rows = [ - _kv(date_filter(m["month"], "F"), _dur(m["playtime"])) for m in months + _tr( + [ + _td( + A( + href=filter_url( + stats_links.sessions_in_month(year, m["month"].month) + ), + class_="hover:underline decoration-dotted", + )[date_filter(m["month"], "F")], + _CELL, + ), + _td(_dur(m["playtime"])), + ] + ) + for m in months ] sections.append(_table(month_rows)) @@ -292,44 +403,74 @@ def stats_content(ctx: dict) -> Node: _two_col_table( "Name", ctx.get("top_10_games_by_playtime") or [], - lambda g: GameLink(g.id, g.name), + lambda g: Fragment(GameLink(g.id, g.name), _session_link(g.id, year)), lambda g: _dur(g.total_playtime), + view_all_url=filter_url(stats_links.games_played(year), sort="-playtime"), ), _h1("Platforms by playtime"), _two_col_table( "Platform", ctx.get("total_playtime_per_platform") or [], - lambda item: item["platform_name"], + lambda item: A( + href=filter_url( + stats_links.sessions_for_platform(item["platform_id"], year) + ), + class_="hover:underline decoration-dotted", + )[item["platform_name"] or "Unspecified"], lambda item: _dur(item["playtime"]), ), ] all_finished = list(ctx.get("all_finished_this_year") or []) if all_finished: - sections += [_h1("Finished"), _finished_table(all_finished)] + sections += [ + _h1("Finished"), + _finished_table( + all_finished, + view_all_url=filter_url( + stats_links.purchases_finished(year), sort="finished" + ), + total=ctx.get("all_finished_this_year_count"), + ), + ] year_finished = list(ctx.get("this_year_finished_this_year") or []) if year_finished: - sections += [_h1(f"Finished ({year} games)"), _finished_table(year_finished)] + sections += [ + _h1(f"Finished ({year} games)"), + _finished_table( + year_finished, + view_all_url=filter_url( + stats_links.purchases_finished_released(year), sort="finished" + ), + total=ctx.get("this_year_finished_this_year_count"), + ), + ] bought_finished = list(ctx.get("purchased_this_year_finished_this_year") or []) if bought_finished: sections += [ _h1(f"Bought and Finished ({year})"), - _finished_table(bought_finished), + _finished_table( + bought_finished, + view_all_url=filter_url( + stats_links.purchases_bought_and_finished(year), sort="finished" + ), + ), ] unfinished = list(ctx.get("purchased_unfinished") or []) if unfinished: sections += [ _h1("Unfinished Purchases"), - _priced_table(unfinished, currency), + _priced_table( + unfinished, + currency, + view_all_url=filter_url(stats_links.purchases_unfinished(year)), + total=ctx.get("purchased_unfinished_count"), + ), ] - all_purchased = list(ctx.get("all_purchased_this_year") or []) - if all_purchased: - sections += [_h1("All Purchases"), _priced_table(all_purchased, currency)] - return Div( [("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")], sections, diff --git a/games/views/stats_data.py b/games/views/stats_data.py index 6c635f9..82c3793 100644 --- a/games/views/stats_data.py +++ b/games/views/stats_data.py @@ -247,7 +247,7 @@ def compute_stats(year: int | None = None) -> StatsData: .annotate(total_playtime=Sum("sessions__duration_total")) .filter(total_playtime__gt=timedelta(0)) ) - top_games = games_with_playtime.order_by("-total_playtime")[:10] + top_games = games_with_playtime.order_by("-total_playtime") else: games_with_playtime = ( Game.objects.filter(sessions__timestamp_start__year=year) @@ -256,11 +256,16 @@ def compute_stats(year: int | None = None) -> StatsData: ) top_games = games_with_playtime.order_by("-total_playtime") + # platform_id is carried alongside the name so the stats row can link to a + # platform-scoped session list (#65). total_playtime_per_platform = ( - sessions.values("game__platform__name") + sessions.values("game__platform__name", "game__platform__id") .annotate(playtime=Sum(F("duration_total"))) - .annotate(platform_name=F("game__platform__name")) - .values("platform_name", "playtime") + .annotate( + platform_name=F("game__platform__name"), + platform_id=F("game__platform__id"), + ) + .values("platform_name", "platform_id", "playtime") .order_by("-playtime") ) diff --git a/tests/test_filter_bar_labels.py b/tests/test_filter_bar_labels.py new file mode 100644 index 0000000..ba29cac --- /dev/null +++ b/tests/test_filter_bar_labels.py @@ -0,0 +1,18 @@ +"""The filter bar must render a filter whose choice/multi values are bare +(no embedded {id, label}) — e.g. a programmatically built filter from +stats_links — without crashing (#65).""" + +from common.components.filters import _extract_labeled + + +def test_extract_labeled_handles_labeled_dicts(): + assert _extract_labeled([{"id": "game", "label": "Game"}]) == [("game", "Game")] + + +def test_extract_labeled_handles_bare_values(): + # bare scalars (ids/choices) fall back to using the value as its own label + assert _extract_labeled(["game", "dlc"]) == [("game", "game"), ("dlc", "dlc")] + + +def test_extract_labeled_handles_bare_ints(): + assert _extract_labeled([3, 7]) == [("3", "3"), ("7", "7")] diff --git a/tests/test_stats_content_links.py b/tests/test_stats_content_links.py new file mode 100644 index 0000000..3252867 --- /dev/null +++ b/tests/test_stats_content_links.py @@ -0,0 +1,115 @@ +"""Rendering tests: stats page wires rows/counts to filtered-list links (#65).""" + +from datetime import datetime, timedelta, timezone + +import pytest +from django.utils.html import escape + +from games.filters import filter_url +from games.models import Game, Platform, PlayEvent, Purchase, Session +from games.views import stats_links +from games.views.stats_content import stats_content +from games.views.stats_data import compute_stats + +YEAR = 2024 + + +def _dt(month, day, hour=12): + return datetime(YEAR, month, day, hour, 0, tzinfo=timezone.utc) + + +@pytest.fixture +def rendered(db): + pc = Platform.objects.create(name="PC") + # 6 games each played in-year → games-by-playtime exceeds the cap of 5. + games = [] + for index in range(6): + game = Game.objects.create( + name=f"Game {index}", platform=pc, status=Game.Status.PLAYED + ) + start = _dt(6, index + 1) + Session.objects.create( + game=game, + timestamp_start=start, + timestamp_end=start + timedelta(hours=index + 1), + ) + games.append(game) + + abandoned = Game.objects.create( + name="Abandoned", platform=pc, status=Game.Status.ABANDONED + ) + Purchase.objects.create(date_purchased=_dt(1, 5), type=Purchase.GAME).games.set( + [games[0]] + ) + Purchase.objects.create(date_purchased=_dt(2, 5), type=Purchase.GAME).games.set( + [abandoned] + ) # dropped + Purchase.objects.create( + date_purchased=_dt(3, 5), date_refunded=_dt(4, 5), type=Purchase.GAME + ).games.set([games[1]]) # refunded + Purchase.objects.create(date_purchased=_dt(5, 5), type=Purchase.GAME).games.set( + [games[2]] + ) # unfinished + + finished_game = games[0] + PlayEvent.objects.create(game=finished_game, ended=_dt(8, 1)) + + ctx = compute_stats(YEAR) + return {"html": str(stats_content(ctx)), "pc": pc, "games": games} + + +def _href(builder_filter, **extra): + return escape(filter_url(builder_filter, **extra)) + + +def test_total_count_links_to_purchases(rendered): + assert _href(stats_links.purchases_total(YEAR)) in rendered["html"] + + +def test_refunded_count_links_to_refunded_purchases(rendered): + assert _href(stats_links.purchases_refunded(YEAR)) in rendered["html"] + + +def test_dropped_count_links_to_dropped_purchases(rendered): + assert _href(stats_links.purchases_dropped(YEAR)) in rendered["html"] + + +def test_unfinished_count_links_to_unfinished_purchases(rendered): + assert _href(stats_links.purchases_unfinished(YEAR)) in rendered["html"] + + +def test_platform_row_links_to_platform_sessions(rendered): + url = _href(stats_links.sessions_for_platform(rendered["pc"].id, YEAR)) + assert url in rendered["html"] + + +def test_game_row_has_session_link(rendered): + # at least one games-by-playtime game links to its sessions + any_game = rendered["games"][0] + url = _href(stats_links.sessions_for_game(any_game.id, YEAR)) + assert url in rendered["html"] + + +def test_games_by_playtime_capped_with_view_all(rendered): + # 6 games played, capped to 5 → a "View all" link to games_played + assert "View all" in rendered["html"] + view_all = filter_url(stats_links.games_played(YEAR), sort="-playtime") + # the filter portion (before &sort) must be present even after attr-escaping + assert escape(view_all.split("&")[0]) in rendered["html"] + + +def test_all_purchases_section_removed(rendered): + assert "All Purchases" not in rendered["html"] + + +def test_generated_links_resolve_to_200(rendered, client, django_user_model): + """A stats link, when visited, returns 200 with its filter applied.""" + user = django_user_model.objects.create_user(username="u", password="p") + client.force_login(user) + for builder in ( + stats_links.purchases_total(YEAR), + stats_links.purchases_dropped(YEAR), + stats_links.sessions_for_platform(rendered["pc"].id, YEAR), + ): + response = client.get(filter_url(builder), follow=True) + assert response.status_code == 200