diff --git a/common/components.py b/common/components.py
index 26fb8a8..e892b06 100644
--- a/common/components.py
+++ b/common/components.py
@@ -2,12 +2,12 @@ import hashlib
from functools import lru_cache
from typing import Any
+from django.db import models
from django.middleware.csrf import get_token
from django.template.defaultfilters import floatformat
from django.templatetags.static import static
from django.urls import reverse
from django.utils.html import conditional_escape, escape
-from django.db import models
from django.utils.safestring import SafeText, mark_safe
from common.icons import get_icon
@@ -952,7 +952,11 @@ def LinkedPurchase(purchase: Purchase) -> SafeText:
{"".join(f"
{game.name}" for game in purchase.games.all())}
"""
- icon = (purchase.platform.icon if purchase.platform else "unspecified") if game_count == 1 else "unspecified"
+ icon = (
+ (purchase.platform.icon if purchase.platform else "unspecified")
+ if game_count == 1
+ else "unspecified"
+ )
if link_content == "":
raise ValueError("link_content is empty!!")
a_content = Div(
@@ -1190,12 +1194,15 @@ def FilterBar(
if status_options is None:
status_options = [(s.value, s.label) for s in Game.Status]
if platform_options is None:
- platform_options = list(Platform.objects.order_by("name").values_list("id", "name"))
+ platform_options = list(
+ Platform.objects.order_by("name").values_list("id", "name")
+ )
existing: dict = {}
if filter_json:
try:
import json
+
existing = json.loads(filter_json)
except (json.JSONDecodeError, TypeError):
pass
@@ -1232,10 +1239,18 @@ def FilterBar(
year_rel = existing.get("year_released", {})
year_min = str(year_rel.get("value", "")) if isinstance(year_rel, dict) else ""
year_max = str(year_rel.get("value2", "")) if isinstance(year_rel, dict) else ""
- mastered_val = existing.get("mastered", {}).get("value", False) if isinstance(existing.get("mastered"), dict) else False
+ mastered_val = (
+ existing.get("mastered", {}).get("value", False)
+ if isinstance(existing.get("mastered"), dict)
+ else False
+ )
playtime = existing.get("playtime_minutes", {})
- playtime_min = _mins_to_hrs(playtime.get("value", "")) if isinstance(playtime, dict) else ""
- playtime_max = _mins_to_hrs(playtime.get("value2", "")) if isinstance(playtime, dict) else ""
+ playtime_min = (
+ _mins_to_hrs(playtime.get("value", "")) if isinstance(playtime, dict) else ""
+ )
+ playtime_max = (
+ _mins_to_hrs(playtime.get("value2", "")) if isinstance(playtime, dict) else ""
+ )
# DB-backed ranges for sliders
try:
@@ -1245,14 +1260,16 @@ def FilterBar(
except Exception:
year_agg = {}
try:
- pt_agg = Game.objects.aggregate(
- pt_max=models.Max("playtime")
- )
+ pt_agg = Game.objects.aggregate(pt_max=models.Max("playtime"))
except Exception:
pt_agg = {}
yr_data_min = max(int(year_agg.get("yr_min") or 1970), 1970)
yr_data_max = min(int(year_agg.get("yr_max") or 2030), 2030)
- pt_data_max = int((pt_agg.get("pt_max") or 0).total_seconds() / 3600) if pt_agg.get("pt_max") else 200
+ pt_data_max = (
+ int((pt_agg.get("pt_max") or 0).total_seconds() / 3600)
+ if pt_agg.get("pt_max")
+ else 200
+ )
form_id = "filter-bar-form"
filter_input_id = "filter-json-input"
@@ -1262,16 +1279,32 @@ def FilterBar(
tag_name="div",
attributes=[("class", "flex flex-col gap-1")],
children=[
- Component(tag_name="label", attributes=[
- ("class", "text-xs font-medium text-body uppercase tracking-wide"),
- ], children=[label]),
- Component(tag_name="input", attributes=[
- ("type", "number"), ("name", escape(name)), ("id", escape(name)),
- ("value", escape(value)), ("placeholder", escape(placeholder)),
- ("class", "block w-full rounded-base border border-default-medium "
- "bg-neutral-secondary-medium text-sm text-heading p-2 "
- "focus:ring-brand focus:border-brand"),
- ]),
+ Component(
+ tag_name="label",
+ attributes=[
+ (
+ "class",
+ "text-xs font-medium text-body uppercase tracking-wide",
+ ),
+ ],
+ children=[label],
+ ),
+ Component(
+ tag_name="input",
+ attributes=[
+ ("type", "number"),
+ ("name", escape(name)),
+ ("id", escape(name)),
+ ("value", escape(value)),
+ ("placeholder", escape(placeholder)),
+ (
+ "class",
+ "block w-full rounded-base border border-default-medium "
+ "bg-neutral-secondary-medium text-sm text-heading p-2 "
+ "focus:ring-brand focus:border-brand",
+ ),
+ ],
+ ),
],
)
@@ -1284,20 +1317,20 @@ def FilterBar(
children=[
mark_safe(
f''
f''
@@ -1309,134 +1342,331 @@ def FilterBar(
tag_name="div",
attributes=[("id", "filter-bar"), ("class", "mb-6")],
children=[
- Component(tag_name="button", attributes=[
- ("type", "button"),
- ("onclick", "var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()"),
- ("class", "flex items-center gap-2 text-sm font-medium text-body "
- "hover:text-heading mb-2"),
- ], children=[
- mark_safe(''),
- "Filters",
- ]),
- Component(tag_name="div", attributes=[
- ("id", "filter-bar-body"),
- ("class", "hidden border border-default-medium rounded-base p-4 "
- "bg-neutral-secondary-medium/50"),
- ], children=[
- Component(tag_name="form", attributes=[
- ("id", form_id),
- ("onsubmit", "return applyFilterBar(event)"),
- ], children=[
- Component(tag_name="input", attributes=[
- ("type", "hidden"), ("id", filter_input_id),
- ("name", "filter"), ("value", escape(filter_json)),
- ]),
- Component(tag_name="div", attributes=[
- ("class", "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4"),
- ], children=[
- Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")],
- children=[
- Component(tag_name="label", attributes=[
- ("class", "text-xs font-medium text-body uppercase tracking-wide"),
- ], children=["Status"]),
- SelectableFilter("status", status_options, status_sel, status_excl,
- status_mod, nullable=not Game._meta.get_field("status").has_default()),
- ]),
- Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")],
- children=[
- Component(tag_name="label", attributes=[
- ("class", "text-xs font-medium text-body uppercase tracking-wide"),
- ], children=["Platform"]),
- SelectableFilter("platform", plat_opts_str, plat_sel, plat_excl,
- plat_mod, nullable=Game._meta.get_field("platform").null),
- ]),
- _number("Year Min", "filter-year-min", year_min, "e.g. 2020"),
- _number("Year Max", "filter-year-max", year_max, "e.g. 2024"),
- ]),
- _range("year-range", "filter-year-min", "filter-year-max",
- year_min, year_max, yr_data_min, yr_data_max),
- Component(tag_name="div", attributes=[
- ("class", "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4"),
- ], children=[
- _number("Playtime Min (hrs)", "filter-playtime-min", playtime_min, "e.g. 1"),
- _number("Playtime Max (hrs)", "filter-playtime-max", playtime_max, "e.g. 100"),
- Component(tag_name="div", attributes=[("class", "flex items-end pb-1")],
- children=[
- Component(tag_name="label", attributes=[
- ("class", "flex items-center gap-2 text-sm text-heading"),
- ], children=[
- Component(tag_name="input", attributes=[
- ("type", "checkbox"), ("name", "filter-mastered"),
- ("value", "1"),
- *([("checked", "true")] if mastered_val else []),
- ("class", "rounded border-default-medium "
- "bg-neutral-secondary-medium text-brand focus:ring-brand"),
- ]),
- "Mastered",
- ]),
- ]),
- ]),
- _range("playtime-range", "filter-playtime-min", "filter-playtime-max",
- playtime_min or "0", playtime_max or str(pt_data_max), 0, pt_data_max),
- Component(tag_name="div", attributes=[
- ("class", "flex gap-3 items-center"),
- ], children=[
- Component(tag_name="button", attributes=[
- ("type", "submit"),
- ("class", "px-4 py-2 text-sm font-medium text-white bg-brand "
- "rounded-lg hover:bg-brand-strong focus:ring-4 "
- "focus:ring-brand-medium"),
- ], children=["Apply"]),
- Component(tag_name="button", attributes=[
- ("type", "button"),
- ("onclick", f"clearFilterBar('{form_id}', '{filter_input_id}')"),
- ("class", "px-4 py-2 text-sm font-medium text-gray-900 bg-white "
- "border border-gray-200 rounded-lg hover:bg-gray-100 "
- "dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 "
- "dark:hover:bg-gray-700 dark:hover:text-white"),
- ], children=["Clear"]),
- Component(tag_name="span", attributes=[
- ("class", "flex gap-2 items-center"), ("id", "save-preset-area"),
- ], children=[
- Component(tag_name="input", attributes=[
- ("type", "text"), ("id", "preset-name-input"),
- ("placeholder", "Preset name..."),
- ("class", "hidden px-3 py-2 text-sm rounded-lg border "
- "border-default-medium bg-neutral-secondary-medium "
- "text-heading focus:ring-brand focus:border-brand"),
- ]),
- Component(tag_name="button", attributes=[
- ("type", "button"), ("id", "save-preset-btn"),
- ("onclick", "showPresetNameInput()"),
- ("class", "px-4 py-2 text-sm font-medium text-gray-900 "
- "bg-white border border-gray-200 rounded-lg "
- "hover:bg-gray-100 dark:bg-gray-800 "
- "dark:border-gray-600 dark:text-gray-400 "
- "dark:hover:bg-gray-700 dark:hover:text-white"),
- ], children=["Save Preset"]),
- Component(tag_name="button", attributes=[
- ("type", "button"), ("id", "confirm-save-preset-btn"),
- ("onclick", f"savePreset('{form_id}', '{filter_input_id}', '{preset_save_url}')"),
- ("class", "hidden px-4 py-2 text-sm font-medium text-white "
- "bg-green-700 rounded-lg hover:bg-green-800 "
- "focus:ring-4 focus:ring-green-300"),
- ], children=["Save"]),
- ]),
- Component(tag_name="div", attributes=[
- ("id", "preset-dropdown"), ("class", "relative"),
- ("data-preset-list-url", preset_list_url),
- ], children=[
- Component(tag_name="span", attributes=[
- ("class", "text-sm text-body"),
- ], children=["Loading presets..."]),
- ]),
- ]),
- ]),
- ]),
+ Component(
+ tag_name="button",
+ attributes=[
+ ("type", "button"),
+ (
+ "onclick",
+ "var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()",
+ ),
+ (
+ "class",
+ "flex items-center gap-2 text-sm font-medium text-body "
+ "hover:text-heading mb-2",
+ ),
+ ],
+ children=[
+ mark_safe(
+ ''
+ ),
+ "Filters",
+ ],
+ ),
+ Component(
+ tag_name="div",
+ attributes=[
+ ("id", "filter-bar-body"),
+ (
+ "class",
+ "hidden border border-default-medium rounded-base p-4 "
+ "bg-neutral-secondary-medium/50",
+ ),
+ ],
+ children=[
+ Component(
+ tag_name="form",
+ attributes=[
+ ("id", form_id),
+ ("onsubmit", "return applyFilterBar(event)"),
+ ],
+ children=[
+ Component(
+ tag_name="input",
+ attributes=[
+ ("type", "hidden"),
+ ("id", filter_input_id),
+ ("name", "filter"),
+ ("value", escape(filter_json)),
+ ],
+ ),
+ Component(
+ tag_name="div",
+ attributes=[
+ (
+ "class",
+ "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4",
+ ),
+ ],
+ children=[
+ Component(
+ tag_name="div",
+ attributes=[("class", "flex flex-col gap-1")],
+ children=[
+ Component(
+ tag_name="label",
+ attributes=[
+ (
+ "class",
+ "text-xs font-medium text-body uppercase tracking-wide",
+ ),
+ ],
+ children=["Status"],
+ ),
+ SelectableFilter(
+ "status",
+ status_options,
+ status_sel,
+ status_excl,
+ status_mod,
+ nullable=not Game._meta.get_field(
+ "status"
+ ).has_default(),
+ ),
+ ],
+ ),
+ Component(
+ tag_name="div",
+ attributes=[("class", "flex flex-col gap-1")],
+ children=[
+ Component(
+ tag_name="label",
+ attributes=[
+ (
+ "class",
+ "text-xs font-medium text-body uppercase tracking-wide",
+ ),
+ ],
+ children=["Platform"],
+ ),
+ SelectableFilter(
+ "platform",
+ plat_opts_str,
+ plat_sel,
+ plat_excl,
+ plat_mod,
+ nullable=Game._meta.get_field(
+ "platform"
+ ).null,
+ ),
+ ],
+ ),
+ _number(
+ "Year Min",
+ "filter-year-min",
+ year_min,
+ "e.g. 2020",
+ ),
+ _number(
+ "Year Max",
+ "filter-year-max",
+ year_max,
+ "e.g. 2024",
+ ),
+ ],
+ ),
+ _range(
+ "year-range",
+ "filter-year-min",
+ "filter-year-max",
+ year_min,
+ year_max,
+ yr_data_min,
+ yr_data_max,
+ ),
+ Component(
+ tag_name="div",
+ attributes=[
+ (
+ "class",
+ "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4",
+ ),
+ ],
+ children=[
+ _number(
+ "Playtime Min (hrs)",
+ "filter-playtime-min",
+ playtime_min,
+ "e.g. 1",
+ ),
+ _number(
+ "Playtime Max (hrs)",
+ "filter-playtime-max",
+ playtime_max,
+ "e.g. 100",
+ ),
+ Component(
+ tag_name="div",
+ attributes=[("class", "flex items-end pb-1")],
+ children=[
+ Component(
+ tag_name="label",
+ attributes=[
+ (
+ "class",
+ "flex items-center gap-2 text-sm text-heading",
+ ),
+ ],
+ children=[
+ Component(
+ tag_name="input",
+ attributes=[
+ ("type", "checkbox"),
+ ("name", "filter-mastered"),
+ ("value", "1"),
+ *(
+ [("checked", "true")]
+ if mastered_val
+ else []
+ ),
+ (
+ "class",
+ "rounded border-default-medium "
+ "bg-neutral-secondary-medium text-brand focus:ring-brand",
+ ),
+ ],
+ ),
+ "Mastered",
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ _range(
+ "playtime-range",
+ "filter-playtime-min",
+ "filter-playtime-max",
+ playtime_min or "0",
+ playtime_max or str(pt_data_max),
+ 0,
+ pt_data_max,
+ ),
+ Component(
+ tag_name="div",
+ attributes=[
+ ("class", "flex gap-3 items-center"),
+ ],
+ children=[
+ Component(
+ tag_name="button",
+ attributes=[
+ ("type", "submit"),
+ (
+ "class",
+ "px-4 py-2 text-sm font-medium text-white bg-brand "
+ "rounded-lg hover:bg-brand-strong focus:ring-4 "
+ "focus:ring-brand-medium",
+ ),
+ ],
+ children=["Apply"],
+ ),
+ Component(
+ tag_name="button",
+ attributes=[
+ ("type", "button"),
+ (
+ "onclick",
+ f"clearFilterBar('{form_id}', '{filter_input_id}')",
+ ),
+ (
+ "class",
+ "px-4 py-2 text-sm font-medium text-gray-900 bg-white "
+ "border border-gray-200 rounded-lg hover:bg-gray-100 "
+ "dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 "
+ "dark:hover:bg-gray-700 dark:hover:text-white",
+ ),
+ ],
+ children=["Clear"],
+ ),
+ Component(
+ tag_name="span",
+ attributes=[
+ ("class", "flex gap-2 items-center"),
+ ("id", "save-preset-area"),
+ ],
+ children=[
+ Component(
+ tag_name="input",
+ attributes=[
+ ("type", "text"),
+ ("id", "preset-name-input"),
+ ("placeholder", "Preset name..."),
+ (
+ "class",
+ "hidden px-3 py-2 text-sm rounded-lg border "
+ "border-default-medium bg-neutral-secondary-medium "
+ "text-heading focus:ring-brand focus:border-brand",
+ ),
+ ],
+ ),
+ Component(
+ tag_name="button",
+ attributes=[
+ ("type", "button"),
+ ("id", "save-preset-btn"),
+ (
+ "onclick",
+ "showPresetNameInput()",
+ ),
+ (
+ "class",
+ "px-4 py-2 text-sm font-medium text-gray-900 "
+ "bg-white border border-gray-200 rounded-lg "
+ "hover:bg-gray-100 dark:bg-gray-800 "
+ "dark:border-gray-600 dark:text-gray-400 "
+ "dark:hover:bg-gray-700 dark:hover:text-white",
+ ),
+ ],
+ children=["Save Preset"],
+ ),
+ Component(
+ tag_name="button",
+ attributes=[
+ ("type", "button"),
+ ("id", "confirm-save-preset-btn"),
+ (
+ "onclick",
+ f"savePreset('{form_id}', '{filter_input_id}', '{preset_save_url}')",
+ ),
+ (
+ "class",
+ "hidden px-4 py-2 text-sm font-medium text-white "
+ "bg-green-700 rounded-lg hover:bg-green-800 "
+ "focus:ring-4 focus:ring-green-300",
+ ),
+ ],
+ children=["Save"],
+ ),
+ ],
+ ),
+ Component(
+ tag_name="div",
+ attributes=[
+ ("id", "preset-dropdown"),
+ ("class", "relative"),
+ ("data-preset-list-url", preset_list_url),
+ ],
+ children=[
+ Component(
+ tag_name="span",
+ attributes=[
+ ("class", "text-sm text-body"),
+ ],
+ children=["Loading presets..."],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
],
)
@@ -1479,14 +1709,14 @@ def SelectableFilter(
label = _find_label(options, val)
selected_html += (
f''
- f'\u2713 {escape(label)}'
+ f'\u2713 {escape(label)}'
f' '
)
for val in excluded:
label = _find_label(options, val)
selected_html += (
f''
- f'\u2717 {escape(label)}'
+ f'\u2717 {escape(label)}'
f' '
)
@@ -1504,23 +1734,40 @@ def SelectableFilter(
return Component(
tag_name="div",
attributes=[
- ("class", "sf-container border border-default-medium rounded-base bg-neutral-secondary-medium"),
+ (
+ "class",
+ "sf-container border border-default-medium rounded-base bg-neutral-secondary-medium",
+ ),
("data-selectable-filter", field_name),
*([("data-modifier", modifier)] if modifier else []),
],
children=[
- Component(tag_name="div", attributes=[
- ("class", "sf-selected flex flex-wrap gap-1 p-2 min-h-[28px]"),
- ], children=[mark_safe(active_mod_html + selected_html)]),
- Component(tag_name="input", attributes=[
- ("type", "text"),
- ("class", "sf-search block w-full border-0 border-t border-default-medium "
- "bg-transparent text-sm text-heading p-2 focus:ring-0 focus:outline-hidden"),
- ("placeholder", "Search\u2026"),
- ]),
- Component(tag_name="div", attributes=[
- ("class", "sf-options max-h-40 overflow-y-auto p-1"),
- ], children=[mark_safe(inactive_mod_html + options_html)]),
+ Component(
+ tag_name="div",
+ attributes=[
+ ("class", "sf-selected flex flex-wrap gap-1 p-2 min-h-[28px]"),
+ ],
+ children=[mark_safe(active_mod_html + selected_html)],
+ ),
+ Component(
+ tag_name="input",
+ attributes=[
+ ("type", "text"),
+ (
+ "class",
+ "sf-search block w-full border-0 border-t border-default-medium "
+ "bg-transparent text-sm text-heading p-2 focus:ring-0 focus:outline-hidden",
+ ),
+ ("placeholder", "Search\u2026"),
+ ],
+ ),
+ Component(
+ tag_name="div",
+ attributes=[
+ ("class", "sf-options max-h-40 overflow-y-auto p-1 text-body"),
+ ],
+ children=[mark_safe(inactive_mod_html + options_html)],
+ ),
],
)
@@ -1533,103 +1780,464 @@ def _find_label(options: list[tuple[str, str]], value: str) -> str:
def SessionFilterBar(filter_json="", preset_list_url="", preset_save_url=""):
- from games.models import Game, Device, Session
- game_opts = [(str(k), v) for k, v in Game.objects.order_by("name").values_list("id", "name")]
- dev_opts = [(str(k), v) for k, v in Device.objects.order_by("name").values_list("id", "name")]
+ from games.models import Device, Game, Session
+
+ game_opts = [
+ (str(k), v) for k, v in Game.objects.order_by("name").values_list("id", "name")
+ ]
+ dev_opts = [
+ (str(k), v)
+ for k, v in Device.objects.order_by("name").values_list("id", "name")
+ ]
existing = {}
if filter_json:
- try: import json; existing = json.loads(filter_json)
- except Exception: pass
+ try:
+ import json
+
+ existing = json.loads(filter_json)
+ except Exception:
+ pass
def _gc(f):
raw = existing.get(f, {})
- if not isinstance(raw, dict): return [], [], ""
- v = raw.get("value", []); e = raw.get("excludes", []); m = raw.get("modifier", "")
- if isinstance(v, str): v = [v]
- if isinstance(e, str): e = [e]
+ if not isinstance(raw, dict):
+ return [], [], ""
+ v = raw.get("value", [])
+ e = raw.get("excludes", [])
+ m = raw.get("modifier", "")
+ if isinstance(v, str):
+ v = [v]
+ if isinstance(e, str):
+ e = [e]
return [str(x) for x in (v or [])], [str(x) for x in (e or [])], m or ""
gs, ge, gm = _gc("game")
ds, de, dm = _gc("device")
def _mh(v):
- if v is None or v == "" or v == 0: return ""
- try: m = int(v)
- except: return ""
- if m == 0: return ""
- h = m / 60; return str(int(h)) if h == int(h) else f"{h:.1f}"
+ if v is None or v == "" or v == 0:
+ return ""
+ try:
+ m = int(v)
+ except:
+ return ""
+ if m == 0:
+ return ""
+ h = m / 60
+ return str(int(h)) if h == int(h) else f"{h:.1f}"
dur = existing.get("duration_minutes", {})
dmin = _mh(dur.get("value", "")) if isinstance(dur, dict) else ""
dmax = _mh(dur.get("value2", "")) if isinstance(dur, dict) else ""
- em = existing.get("emulated", {}).get("value", False) if isinstance(existing.get("emulated"), dict) else False
- ac = existing.get("is_active", {}).get("value", False) if isinstance(existing.get("is_active"), dict) else False
+ em = (
+ existing.get("emulated", {}).get("value", False)
+ if isinstance(existing.get("emulated"), dict)
+ else False
+ )
+ ac = (
+ existing.get("is_active", {}).get("value", False)
+ if isinstance(existing.get("is_active"), dict)
+ else False
+ )
try:
a = Session.objects.aggregate(m=models.Max("duration_total"))
- ddm = max(int((a.get("m") or 0).total_seconds() / 3600) if a.get("m") else 200, 1)
- except Exception: ddm = 200
+ ddm = max(
+ int((a.get("m") or 0).total_seconds() / 3600) if a.get("m") else 200, 1
+ )
+ except Exception:
+ ddm = 200
fd, hd = "filter-bar-form", "filter-json-input"
def _n(l, n, v="", p=""):
- return Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[
- Component(tag_name="label", attributes=[("class", "text-xs font-medium text-body uppercase tracking-wide")], children=[l]),
- Component(tag_name="input", attributes=[("type", "number"), ("name", escape(n)), ("id", escape(n)), ("value", escape(v)), ("placeholder", escape(p)), ("class", "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 focus:ring-brand focus:border-brand")]),
- ])
+ return Component(
+ tag_name="div",
+ attributes=[("class", "flex flex-col gap-1")],
+ children=[
+ Component(
+ tag_name="label",
+ attributes=[
+ (
+ "class",
+ "text-xs font-medium text-body uppercase tracking-wide",
+ )
+ ],
+ children=[l],
+ ),
+ Component(
+ tag_name="input",
+ attributes=[
+ ("type", "number"),
+ ("name", escape(n)),
+ ("id", escape(n)),
+ ("value", escape(v)),
+ ("placeholder", escape(p)),
+ (
+ "class",
+ "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 focus:ring-brand focus:border-brand",
+ ),
+ ],
+ ),
+ ],
+ )
def _r(cls, mi, mx, iv, xv, lo, hi, s="1"):
- return Component(tag_name="div", attributes=[("class", f"range-slider {cls} relative h-10 mt-1 mb-2 select-none"), ("data-min", str(lo)), ("data-max", str(hi)), ("data-step", str(s))], children=[
- mark_safe(''+f''),
- ])
+ return Component(
+ tag_name="div",
+ attributes=[
+ ("class", f"range-slider {cls} relative h-10 mt-1 mb-2 select-none"),
+ ("data-min", str(lo)),
+ ("data-max", str(hi)),
+ ("data-step", str(s)),
+ ],
+ children=[
+ mark_safe(
+ ''
+ + f''
+ ),
+ ],
+ )
- return Component(tag_name="div", attributes=[("id", "filter-bar"), ("class", "mb-6")], children=[
- Component(tag_name="button", attributes=[("type", "button"), ("onclick", "var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()"), ("class", "flex items-center gap-2 text-sm font-medium text-body hover:text-heading mb-2")], children=[mark_safe(''), "Filters"]),
- Component(tag_name="div", attributes=[("id", "filter-bar-body"), ("class", "hidden border border-default-medium rounded-base p-4 bg-neutral-secondary-medium/50")], children=[
- Component(tag_name="form", attributes=[("id", fd), ("onsubmit", "return applyFilterBar(event)")], children=[
- Component(tag_name="input", attributes=[("type", "hidden"), ("id", hd), ("name", "filter"), ("value", escape(filter_json))]),
- Component(tag_name="div", attributes=[("class", "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4")], children=[
- Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[Component(tag_name="label", attributes=[("class", "text-xs font-medium text-body uppercase tracking-wide")], children=["Game"]), SelectableFilter("game", game_opts, gs, ge, gm, nullable=not Game._meta.get_field("name").has_default())]),
- Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[Component(tag_name="label", attributes=[("class", "text-xs font-medium text-body uppercase tracking-wide")], children=["Device"]), SelectableFilter("device", dev_opts, ds, de, dm, nullable=Session._meta.get_field("device").null)]),
- _n("Duration Min (hrs)", "filter-playtime-min", dmin, "e.g. 0.5"),
- _n("Duration Max (hrs)", "filter-playtime-max", dmax, "e.g. 10"),
- ]),
- _r("dur-range", "filter-playtime-min", "filter-playtime-max", dmin or "0", dmax or str(ddm), 0, ddm),
- Component(tag_name="div", attributes=[("class", "flex gap-4 mb-4")], children=[
- Component(tag_name="label", attributes=[("class", "flex items-center gap-2 text-sm text-heading")], children=[Component(tag_name="input", attributes=[("type", "checkbox"), ("name", "filter-emulated"), ("value", "1"), *([("checked", "true")] if em else []), ("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand")]), "Emulated"]),
- Component(tag_name="label", attributes=[("class", "flex items-center gap-2 text-sm text-heading")], children=[Component(tag_name="input", attributes=[("type", "checkbox"), ("name", "filter-active"), ("value", "1"), *([("checked", "true")] if ac else []), ("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand")]), "Active"]),
- ]),
- Component(tag_name="div", attributes=[("class", "flex gap-3 items-center")], children=[
- Component(tag_name="button", attributes=[("type", "submit"), ("class", "px-4 py-2 text-sm font-medium text-white bg-brand rounded-lg hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium")], children=["Apply"]),
- Component(tag_name="button", attributes=[("type", "button"), ("onclick", f"clearFilterBar('{fd}', '{hd}')"), ("class", "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white")], children=["Clear"]),
- Component(tag_name="span", attributes=[("class", "flex gap-2 items-center"), ("id", "save-preset-area")], children=[
- Component(tag_name="input", attributes=[("type", "text"), ("id", "preset-name-input"), ("placeholder", "Preset name..."), ("class", "hidden px-3 py-2 text-sm rounded-lg border border-default-medium bg-neutral-secondary-medium text-heading focus:ring-brand focus:border-brand")]),
- Component(tag_name="button", attributes=[("type", "button"), ("id", "save-preset-btn"), ("onclick", "showPresetNameInput()"), ("class", "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white")], children=["Save Preset"]),
- Component(tag_name="button", attributes=[("type", "button"), ("id", "confirm-save-preset-btn"), ("onclick", f"savePreset('{fd}', '{hd}', '{preset_save_url}')"), ("class", "hidden px-4 py-2 text-sm font-medium text-white bg-green-700 rounded-lg hover:bg-green-800 focus:ring-4 focus:ring-green-300")], children=["Save"]),
- ]),
- Component(tag_name="div", attributes=[("id", "preset-dropdown"), ("class", "relative"), ("data-preset-list-url", preset_list_url)], children=[Component(tag_name="span", attributes=[("class", "text-sm text-body")], children=["Loading presets..."])]),
- ]),
- ]),
- ]),
- ])
+ return Component(
+ tag_name="div",
+ attributes=[("id", "filter-bar"), ("class", "mb-6")],
+ children=[
+ Component(
+ tag_name="button",
+ attributes=[
+ ("type", "button"),
+ (
+ "onclick",
+ "var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()",
+ ),
+ (
+ "class",
+ "flex items-center gap-2 text-sm font-medium text-body hover:text-heading mb-2",
+ ),
+ ],
+ children=[
+ mark_safe(
+ ''
+ ),
+ "Filters",
+ ],
+ ),
+ Component(
+ tag_name="div",
+ attributes=[
+ ("id", "filter-bar-body"),
+ (
+ "class",
+ "hidden border border-default-medium rounded-base p-4 bg-neutral-secondary-medium/50",
+ ),
+ ],
+ children=[
+ Component(
+ tag_name="form",
+ attributes=[
+ ("id", fd),
+ ("onsubmit", "return applyFilterBar(event)"),
+ ],
+ children=[
+ Component(
+ tag_name="input",
+ attributes=[
+ ("type", "hidden"),
+ ("id", hd),
+ ("name", "filter"),
+ ("value", escape(filter_json)),
+ ],
+ ),
+ Component(
+ tag_name="div",
+ attributes=[
+ (
+ "class",
+ "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4",
+ )
+ ],
+ children=[
+ Component(
+ tag_name="div",
+ attributes=[("class", "flex flex-col gap-1")],
+ children=[
+ Component(
+ tag_name="label",
+ attributes=[
+ (
+ "class",
+ "text-xs font-medium text-body uppercase tracking-wide",
+ )
+ ],
+ children=["Game"],
+ ),
+ SelectableFilter(
+ "game",
+ game_opts,
+ gs,
+ ge,
+ gm,
+ nullable=not Game._meta.get_field(
+ "name"
+ ).has_default(),
+ ),
+ ],
+ ),
+ Component(
+ tag_name="div",
+ attributes=[("class", "flex flex-col gap-1")],
+ children=[
+ Component(
+ tag_name="label",
+ attributes=[
+ (
+ "class",
+ "text-xs font-medium text-body uppercase tracking-wide",
+ )
+ ],
+ children=["Device"],
+ ),
+ SelectableFilter(
+ "device",
+ dev_opts,
+ ds,
+ de,
+ dm,
+ nullable=Session._meta.get_field(
+ "device"
+ ).null,
+ ),
+ ],
+ ),
+ _n(
+ "Duration Min (hrs)",
+ "filter-playtime-min",
+ dmin,
+ "e.g. 0.5",
+ ),
+ _n(
+ "Duration Max (hrs)",
+ "filter-playtime-max",
+ dmax,
+ "e.g. 10",
+ ),
+ ],
+ ),
+ _r(
+ "dur-range",
+ "filter-playtime-min",
+ "filter-playtime-max",
+ dmin or "0",
+ dmax or str(ddm),
+ 0,
+ ddm,
+ ),
+ Component(
+ tag_name="div",
+ attributes=[("class", "flex gap-4 mb-4")],
+ children=[
+ Component(
+ tag_name="label",
+ attributes=[
+ (
+ "class",
+ "flex items-center gap-2 text-sm text-heading",
+ )
+ ],
+ children=[
+ Component(
+ tag_name="input",
+ attributes=[
+ ("type", "checkbox"),
+ ("name", "filter-emulated"),
+ ("value", "1"),
+ *(
+ [("checked", "true")]
+ if em
+ else []
+ ),
+ (
+ "class",
+ "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand",
+ ),
+ ],
+ ),
+ "Emulated",
+ ],
+ ),
+ Component(
+ tag_name="label",
+ attributes=[
+ (
+ "class",
+ "flex items-center gap-2 text-sm text-heading",
+ )
+ ],
+ children=[
+ Component(
+ tag_name="input",
+ attributes=[
+ ("type", "checkbox"),
+ ("name", "filter-active"),
+ ("value", "1"),
+ *(
+ [("checked", "true")]
+ if ac
+ else []
+ ),
+ (
+ "class",
+ "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand",
+ ),
+ ],
+ ),
+ "Active",
+ ],
+ ),
+ ],
+ ),
+ Component(
+ tag_name="div",
+ attributes=[("class", "flex gap-3 items-center")],
+ children=[
+ Component(
+ tag_name="button",
+ attributes=[
+ ("type", "submit"),
+ (
+ "class",
+ "px-4 py-2 text-sm font-medium text-white bg-brand rounded-lg hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium",
+ ),
+ ],
+ children=["Apply"],
+ ),
+ Component(
+ tag_name="button",
+ attributes=[
+ ("type", "button"),
+ (
+ "onclick",
+ f"clearFilterBar('{fd}', '{hd}')",
+ ),
+ (
+ "class",
+ "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white",
+ ),
+ ],
+ children=["Clear"],
+ ),
+ Component(
+ tag_name="span",
+ attributes=[
+ ("class", "flex gap-2 items-center"),
+ ("id", "save-preset-area"),
+ ],
+ children=[
+ Component(
+ tag_name="input",
+ attributes=[
+ ("type", "text"),
+ ("id", "preset-name-input"),
+ ("placeholder", "Preset name..."),
+ (
+ "class",
+ "hidden px-3 py-2 text-sm rounded-lg border border-default-medium bg-neutral-secondary-medium text-heading focus:ring-brand focus:border-brand",
+ ),
+ ],
+ ),
+ Component(
+ tag_name="button",
+ attributes=[
+ ("type", "button"),
+ ("id", "save-preset-btn"),
+ (
+ "onclick",
+ "showPresetNameInput()",
+ ),
+ (
+ "class",
+ "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white",
+ ),
+ ],
+ children=["Save Preset"],
+ ),
+ Component(
+ tag_name="button",
+ attributes=[
+ ("type", "button"),
+ ("id", "confirm-save-preset-btn"),
+ (
+ "onclick",
+ f"savePreset('{fd}', '{hd}', '{preset_save_url}')",
+ ),
+ (
+ "class",
+ "hidden px-4 py-2 text-sm font-medium text-white bg-green-700 rounded-lg hover:bg-green-800 focus:ring-4 focus:ring-green-300",
+ ),
+ ],
+ children=["Save"],
+ ),
+ ],
+ ),
+ Component(
+ tag_name="div",
+ attributes=[
+ ("id", "preset-dropdown"),
+ ("class", "relative"),
+ ("data-preset-list-url", preset_list_url),
+ ],
+ children=[
+ Component(
+ tag_name="span",
+ attributes=[
+ ("class", "text-sm text-body")
+ ],
+ children=["Loading presets..."],
+ )
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ )
def PurchaseFilterBar(filter_json="", preset_list_url="", preset_save_url=""):
from games.models import Game, Platform, Purchase
- game_opts = [(str(k), v) for k, v in Game.objects.order_by("name").values_list("id", "name")]
- plat_opts = [(str(k), v) for k, v in Platform.objects.order_by("name").values_list("id", "name")]
+
+ game_opts = [
+ (str(k), v) for k, v in Game.objects.order_by("name").values_list("id", "name")
+ ]
+ plat_opts = [
+ (str(k), v)
+ for k, v in Platform.objects.order_by("name").values_list("id", "name")
+ ]
type_opts = [(t[0], t[1]) for t in Purchase.TYPES]
own_opts = [(t[0], t[1]) for t in Purchase.OWNERSHIP_TYPES]
existing = {}
if filter_json:
- try: import json; existing = json.loads(filter_json)
- except Exception: pass
+ try:
+ import json
+
+ existing = json.loads(filter_json)
+ except Exception:
+ pass
def _gc(f):
raw = existing.get(f, {})
- if not isinstance(raw, dict): return [], [], ""
- v = raw.get("value", []); e = raw.get("excludes", []); m = raw.get("modifier", "")
- if isinstance(v, str): v = [v]
- if isinstance(e, str): e = [e]
+ if not isinstance(raw, dict):
+ return [], [], ""
+ v = raw.get("value", [])
+ e = raw.get("excludes", [])
+ m = raw.get("modifier", "")
+ if isinstance(v, str):
+ v = [v]
+ if isinstance(e, str):
+ e = [e]
return [str(x) for x in (v or [])], [str(x) for x in (e or [])], m or ""
gs, ge, gm = _gc("games")
@@ -1639,52 +2247,387 @@ def PurchaseFilterBar(filter_json="", preset_list_url="", preset_save_url=""):
price = existing.get("price", {})
pmin = str(price.get("value", "")) if isinstance(price, dict) else ""
pmax = str(price.get("value2", "")) if isinstance(price, dict) else ""
- rf = existing.get("is_refunded", {}).get("value", False) if isinstance(existing.get("is_refunded"), dict) else False
+ rf = (
+ existing.get("is_refunded", {}).get("value", False)
+ if isinstance(existing.get("is_refunded"), dict)
+ else False
+ )
try:
a = Purchase.objects.aggregate(lo=models.Min("price"), hi=models.Max("price"))
plo, phi = int(a.get("lo") or 0), max(int(a.get("hi") or 100), 1)
- except Exception: plo, phi = 0, 100
+ except Exception:
+ plo, phi = 0, 100
fd, hd = "filter-bar-form", "filter-json-input"
def _n(l, n, v="", p=""):
- return Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[
- Component(tag_name="label", attributes=[("class", "text-xs font-medium text-body uppercase tracking-wide")], children=[l]),
- Component(tag_name="input", attributes=[("type", "number"), ("name", escape(n)), ("id", escape(n)), ("value", escape(v)), ("placeholder", escape(p)), ("class", "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 focus:ring-brand focus:border-brand")]),
- ])
+ return Component(
+ tag_name="div",
+ attributes=[("class", "flex flex-col gap-1")],
+ children=[
+ Component(
+ tag_name="label",
+ attributes=[
+ (
+ "class",
+ "text-xs font-medium text-body uppercase tracking-wide",
+ )
+ ],
+ children=[l],
+ ),
+ Component(
+ tag_name="input",
+ attributes=[
+ ("type", "number"),
+ ("name", escape(n)),
+ ("id", escape(n)),
+ ("value", escape(v)),
+ ("placeholder", escape(p)),
+ (
+ "class",
+ "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 focus:ring-brand focus:border-brand",
+ ),
+ ],
+ ),
+ ],
+ )
def _r(cls, mi, mx, iv, xv, lo, hi, s="1"):
- return Component(tag_name="div", attributes=[("class", f"range-slider {cls} relative h-10 mt-1 mb-2 select-none"), ("data-min", str(lo)), ("data-max", str(hi)), ("data-step", str(s))], children=[
- mark_safe(''+f''),
- ])
+ return Component(
+ tag_name="div",
+ attributes=[
+ ("class", f"range-slider {cls} relative h-10 mt-1 mb-2 select-none"),
+ ("data-min", str(lo)),
+ ("data-max", str(hi)),
+ ("data-step", str(s)),
+ ],
+ children=[
+ mark_safe(
+ ''
+ + f''
+ ),
+ ],
+ )
- return Component(tag_name="div", attributes=[("id", "filter-bar"), ("class", "mb-6")], children=[
- Component(tag_name="button", attributes=[("type", "button"), ("onclick", "var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()"), ("class", "flex items-center gap-2 text-sm font-medium text-body hover:text-heading mb-2")], children=[mark_safe(''), "Filters"]),
- Component(tag_name="div", attributes=[("id", "filter-bar-body"), ("class", "hidden border border-default-medium rounded-base p-4 bg-neutral-secondary-medium/50")], children=[
- Component(tag_name="form", attributes=[("id", fd), ("onsubmit", "return applyFilterBar(event)")], children=[
- Component(tag_name="input", attributes=[("type", "hidden"), ("id", hd), ("name", "filter"), ("value", escape(filter_json))]),
- Component(tag_name="div", attributes=[("class", "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4")], children=[
- Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[Component(tag_name="label", attributes=[("class", "text-xs font-medium text-body uppercase tracking-wide")], children=["Game"]), SelectableFilter("games", game_opts, gs, ge, gm, nullable=False)]),
- Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[Component(tag_name="label", attributes=[("class", "text-xs font-medium text-body uppercase tracking-wide")], children=["Platform"]), SelectableFilter("platform", plat_opts, ps, pe, pm, nullable=Purchase._meta.get_field("platform").null)]),
- Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[Component(tag_name="label", attributes=[("class", "text-xs font-medium text-body uppercase tracking-wide")], children=["Type"]), SelectableFilter("type", type_opts, ts, te, tm, nullable=not Purchase._meta.get_field("type").has_default())]),
- Component(tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[Component(tag_name="label", attributes=[("class", "text-xs font-medium text-body uppercase tracking-wide")], children=["Ownership"]), SelectableFilter("ownership_type", own_opts, os, oe, om, nullable=not Purchase._meta.get_field("ownership_type").has_default())]),
- ]),
- Component(tag_name="div", attributes=[("class", "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4")], children=[
- _n("Price Min", "filter-price-min", pmin, "0.00"),
- _n("Price Max", "filter-price-max", pmax, "100.00"),
- Component(tag_name="label", attributes=[("class", "flex items-center gap-2 text-sm text-heading")], children=[Component(tag_name="input", attributes=[("type", "checkbox"), ("name", "filter-refunded"), ("value", "1"), *([("checked", "true")] if rf else []), ("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand")]), "Refunded"]),
- ]),
- _r("price-range", "filter-price-min", "filter-price-max", pmin or str(plo), pmax or str(phi), plo, phi),
- Component(tag_name="div", attributes=[("class", "flex gap-3 items-center")], children=[
- Component(tag_name="button", attributes=[("type", "submit"), ("class", "px-4 py-2 text-sm font-medium text-white bg-brand rounded-lg hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium")], children=["Apply"]),
- Component(tag_name="button", attributes=[("type", "button"), ("onclick", f"clearFilterBar('{fd}', '{hd}')"), ("class", "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white")], children=["Clear"]),
- Component(tag_name="span", attributes=[("class", "flex gap-2 items-center"), ("id", "save-preset-area")], children=[
- Component(tag_name="input", attributes=[("type", "text"), ("id", "preset-name-input"), ("placeholder", "Preset name..."), ("class", "hidden px-3 py-2 text-sm rounded-lg border border-default-medium bg-neutral-secondary-medium text-heading focus:ring-brand focus:border-brand")]),
- Component(tag_name="button", attributes=[("type", "button"), ("id", "save-preset-btn"), ("onclick", "showPresetNameInput()"), ("class", "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white")], children=["Save Preset"]),
- Component(tag_name="button", attributes=[("type", "button"), ("id", "confirm-save-preset-btn"), ("onclick", f"savePreset('{fd}', '{hd}', '{preset_save_url}')"), ("class", "hidden px-4 py-2 text-sm font-medium text-white bg-green-700 rounded-lg hover:bg-green-800 focus:ring-4 focus:ring-green-300")], children=["Save"]),
- ]),
- Component(tag_name="div", attributes=[("id", "preset-dropdown"), ("class", "relative"), ("data-preset-list-url", preset_list_url)], children=[Component(tag_name="span", attributes=[("class", "text-sm text-body")], children=["Loading presets..."])]),
- ]),
- ]),
- ]),
- ])
+ return Component(
+ tag_name="div",
+ attributes=[("id", "filter-bar"), ("class", "mb-6")],
+ children=[
+ Component(
+ tag_name="button",
+ attributes=[
+ ("type", "button"),
+ (
+ "onclick",
+ "var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()",
+ ),
+ (
+ "class",
+ "flex items-center gap-2 text-sm font-medium text-body hover:text-heading mb-2",
+ ),
+ ],
+ children=[
+ mark_safe(
+ ''
+ ),
+ "Filters",
+ ],
+ ),
+ Component(
+ tag_name="div",
+ attributes=[
+ ("id", "filter-bar-body"),
+ (
+ "class",
+ "hidden border border-default-medium rounded-base p-4 bg-neutral-secondary-medium/50",
+ ),
+ ],
+ children=[
+ Component(
+ tag_name="form",
+ attributes=[
+ ("id", fd),
+ ("onsubmit", "return applyFilterBar(event)"),
+ ],
+ children=[
+ Component(
+ tag_name="input",
+ attributes=[
+ ("type", "hidden"),
+ ("id", hd),
+ ("name", "filter"),
+ ("value", escape(filter_json)),
+ ],
+ ),
+ Component(
+ tag_name="div",
+ attributes=[
+ (
+ "class",
+ "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4",
+ )
+ ],
+ children=[
+ Component(
+ tag_name="div",
+ attributes=[("class", "flex flex-col gap-1")],
+ children=[
+ Component(
+ tag_name="label",
+ attributes=[
+ (
+ "class",
+ "text-xs font-medium text-body uppercase tracking-wide",
+ )
+ ],
+ children=["Game"],
+ ),
+ SelectableFilter(
+ "games",
+ game_opts,
+ gs,
+ ge,
+ gm,
+ nullable=False,
+ ),
+ ],
+ ),
+ Component(
+ tag_name="div",
+ attributes=[("class", "flex flex-col gap-1")],
+ children=[
+ Component(
+ tag_name="label",
+ attributes=[
+ (
+ "class",
+ "text-xs font-medium text-body uppercase tracking-wide",
+ )
+ ],
+ children=["Platform"],
+ ),
+ SelectableFilter(
+ "platform",
+ plat_opts,
+ ps,
+ pe,
+ pm,
+ nullable=Purchase._meta.get_field(
+ "platform"
+ ).null,
+ ),
+ ],
+ ),
+ Component(
+ tag_name="div",
+ attributes=[("class", "flex flex-col gap-1")],
+ children=[
+ Component(
+ tag_name="label",
+ attributes=[
+ (
+ "class",
+ "text-xs font-medium text-body uppercase tracking-wide",
+ )
+ ],
+ children=["Type"],
+ ),
+ SelectableFilter(
+ "type",
+ type_opts,
+ ts,
+ te,
+ tm,
+ nullable=not Purchase._meta.get_field(
+ "type"
+ ).has_default(),
+ ),
+ ],
+ ),
+ Component(
+ tag_name="div",
+ attributes=[("class", "flex flex-col gap-1")],
+ children=[
+ Component(
+ tag_name="label",
+ attributes=[
+ (
+ "class",
+ "text-xs font-medium text-body uppercase tracking-wide",
+ )
+ ],
+ children=["Ownership"],
+ ),
+ SelectableFilter(
+ "ownership_type",
+ own_opts,
+ os,
+ oe,
+ om,
+ nullable=not Purchase._meta.get_field(
+ "ownership_type"
+ ).has_default(),
+ ),
+ ],
+ ),
+ ],
+ ),
+ Component(
+ tag_name="div",
+ attributes=[
+ (
+ "class",
+ "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4",
+ )
+ ],
+ children=[
+ _n("Price Min", "filter-price-min", pmin, "0.00"),
+ _n("Price Max", "filter-price-max", pmax, "100.00"),
+ Component(
+ tag_name="label",
+ attributes=[
+ (
+ "class",
+ "flex items-center gap-2 text-sm text-heading",
+ )
+ ],
+ children=[
+ Component(
+ tag_name="input",
+ attributes=[
+ ("type", "checkbox"),
+ ("name", "filter-refunded"),
+ ("value", "1"),
+ *(
+ [("checked", "true")]
+ if rf
+ else []
+ ),
+ (
+ "class",
+ "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand",
+ ),
+ ],
+ ),
+ "Refunded",
+ ],
+ ),
+ ],
+ ),
+ _r(
+ "price-range",
+ "filter-price-min",
+ "filter-price-max",
+ pmin or str(plo),
+ pmax or str(phi),
+ plo,
+ phi,
+ ),
+ Component(
+ tag_name="div",
+ attributes=[("class", "flex gap-3 items-center")],
+ children=[
+ Component(
+ tag_name="button",
+ attributes=[
+ ("type", "submit"),
+ (
+ "class",
+ "px-4 py-2 text-sm font-medium text-white bg-brand rounded-lg hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium",
+ ),
+ ],
+ children=["Apply"],
+ ),
+ Component(
+ tag_name="button",
+ attributes=[
+ ("type", "button"),
+ (
+ "onclick",
+ f"clearFilterBar('{fd}', '{hd}')",
+ ),
+ (
+ "class",
+ "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white",
+ ),
+ ],
+ children=["Clear"],
+ ),
+ Component(
+ tag_name="span",
+ attributes=[
+ ("class", "flex gap-2 items-center"),
+ ("id", "save-preset-area"),
+ ],
+ children=[
+ Component(
+ tag_name="input",
+ attributes=[
+ ("type", "text"),
+ ("id", "preset-name-input"),
+ ("placeholder", "Preset name..."),
+ (
+ "class",
+ "hidden px-3 py-2 text-sm rounded-lg border border-default-medium bg-neutral-secondary-medium text-heading focus:ring-brand focus:border-brand",
+ ),
+ ],
+ ),
+ Component(
+ tag_name="button",
+ attributes=[
+ ("type", "button"),
+ ("id", "save-preset-btn"),
+ (
+ "onclick",
+ "showPresetNameInput()",
+ ),
+ (
+ "class",
+ "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white",
+ ),
+ ],
+ children=["Save Preset"],
+ ),
+ Component(
+ tag_name="button",
+ attributes=[
+ ("type", "button"),
+ ("id", "confirm-save-preset-btn"),
+ (
+ "onclick",
+ f"savePreset('{fd}', '{hd}', '{preset_save_url}')",
+ ),
+ (
+ "class",
+ "hidden px-4 py-2 text-sm font-medium text-white bg-green-700 rounded-lg hover:bg-green-800 focus:ring-4 focus:ring-green-300",
+ ),
+ ],
+ children=["Save"],
+ ),
+ ],
+ ),
+ Component(
+ tag_name="div",
+ attributes=[
+ ("id", "preset-dropdown"),
+ ("class", "relative"),
+ ("data-preset-list-url", preset_list_url),
+ ],
+ children=[
+ Component(
+ tag_name="span",
+ attributes=[
+ ("class", "text-sm text-body")
+ ],
+ children=["Loading presets..."],
+ )
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ )
diff --git a/games/views/general.py b/games/views/general.py
index 7ec223c..1be95eb 100644
--- a/games/views/general.py
+++ b/games/views/general.py
@@ -3,19 +3,9 @@ from typing import Any, Callable
from django.contrib.auth.decorators import login_required
from django.db.models import (
- Avg,
- Count,
- ExpressionWrapper,
F,
- Max,
- OuterRef,
- Prefetch,
- Q,
- Subquery,
Sum,
- fields,
)
-from django.db.models.functions import TruncDate, TruncMonth
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect
@@ -23,10 +13,10 @@ from django.urls import reverse
from django.utils.timezone import now as timezone_now
from common.layout import render_page
-from common.time import available_stats_year_range, dateformat, format_duration
-from common.utils import safe_division
+from common.time import format_duration
from games.models import Game, Platform, Purchase, Session
from games.views.stats_content import stats_content
+from games.views.stats_data import compute_stats
def model_counts(request: HttpRequest) -> dict[str, bool]:
@@ -75,210 +65,9 @@ def use_custom_redirect(
@login_required
def stats_alltime(request: HttpRequest) -> HttpResponse:
- year = "Alltime"
- this_year_sessions = Session.objects.all().prefetch_related(Prefetch("game"))
- this_year_sessions_with_durations = this_year_sessions.annotate(
- duration=ExpressionWrapper(
- F("timestamp_end") - F("timestamp_start"),
- output_field=fields.DurationField(),
- )
- )
- longest_session = this_year_sessions_with_durations.order_by("-duration").first()
- this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct()
- this_year_games_with_session_counts = this_year_games.annotate(
- session_count=Count("sessions"),
- )
- game_highest_session_count = this_year_games_with_session_counts.order_by(
- "-session_count"
- ).first()
- selected_currency = "CZK"
- unique_days = (
- this_year_sessions.annotate(date=TruncDate("timestamp_start"))
- .values("date")
- .distinct()
- .aggregate(dates=Count("date"))
- )
- this_year_played_purchases = Purchase.objects.filter(
- games__sessions__in=this_year_sessions
- ).distinct()
-
- this_year_purchases = Purchase.objects.all()
- this_year_purchases_with_currency = this_year_purchases.select_related("games")
- this_year_purchases_without_refunded = Purchase.objects.filter(date_refunded=None)
- this_year_purchases_refunded = Purchase.objects.refunded()
-
- this_year_purchases_unfinished_dropped_nondropped = (
- this_year_purchases_without_refunded.filter(
- ~Q(games__status=Game.Status.FINISHED)
- & ~Q(games__playevents__ended__isnull=False)
- )
- .filter(infinite=False)
- .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
- ) # do not count battle passes etc.
-
- this_year_purchases_unfinished = (
- this_year_purchases_unfinished_dropped_nondropped.filter(
- ~Q(games__status=Game.Status.RETIRED)
- & ~Q(games__status=Game.Status.ABANDONED)
- )
- )
- this_year_purchases_dropped = (
- this_year_purchases.filter(
- ~Q(games__status=Game.Status.FINISHED)
- & ~Q(games__playevents__ended__isnull=False)
- )
- .filter(Q(games__status=Game.Status.ABANDONED) | Q(date_refunded__isnull=False))
- .filter(infinite=False)
- .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
- )
-
- this_year_purchases_without_refunded_count = (
- this_year_purchases_without_refunded.count()
- )
- this_year_purchases_unfinished_count = this_year_purchases_unfinished.count()
- this_year_purchases_unfinished_percent = int(
- safe_division(
- this_year_purchases_unfinished_count,
- this_year_purchases_without_refunded_count,
- )
- * 100
- )
-
- _finished_purchases_qs = Purchase.objects.finished()
- _finished_with_date = _finished_purchases_qs.annotate(
- date_finished=Subquery(
- Purchase.objects.filter(pk=OuterRef("pk"))
- .annotate(max_ended=Max("games__playevents__ended"))
- .values("max_ended")[:1]
- )
- )
- purchases_finished_this_year = _finished_with_date
- purchases_finished_this_year_released_this_year = _finished_with_date.order_by(
- "-date_finished"
- )
-
- this_year_spendings = this_year_purchases_without_refunded.aggregate(
- total_spent=Sum(F("converted_price"))
- )
- total_spent = this_year_spendings["total_spent"] or 0
-
- games_with_playtime = (
- Game.objects.filter(sessions__in=this_year_sessions)
- .distinct()
- .annotate(total_playtime=Sum(F("sessions__duration_total")))
- .filter(total_playtime__gt=timedelta(0))
- )
- month_playtimes = (
- this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
- .values("month")
- .annotate(playtime=Sum("duration_total"))
- .order_by("month")
- )
- for month in month_playtimes:
- month["playtime"] = format_duration(month["playtime"], "%2.0H")
-
- highest_session_average_game = (
- Game.objects.filter(sessions__in=this_year_sessions)
- .annotate(session_average=Avg("sessions__duration_calculated"))
- .order_by("-session_average")
- .first()
- )
- top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
-
- total_playtime_per_platform = (
- this_year_sessions.values("game__platform__name")
- .annotate(playtime=Sum(F("duration_total")))
- .annotate(platform_name=F("game__platform__name"))
- .values("platform_name", "playtime")
- .order_by("-playtime")
- )
-
- backlog_decrease_count = purchases_finished_this_year.count()
-
- first_play_date = "N/A"
- last_play_date = "N/A"
- first_play_game = None
- last_play_game = None
- if this_year_sessions:
- first_session = this_year_sessions.earliest()
- first_play_game = first_session.game
- first_play_date = first_session.timestamp_start.strftime(dateformat)
- last_session = this_year_sessions.latest()
- last_play_game = last_session.game
- last_play_date = last_session.timestamp_start.strftime(dateformat)
-
- all_purchased_this_year_count = this_year_purchases_with_currency.count()
- all_purchased_refunded_this_year_count: int = this_year_purchases_refunded.count()
-
- this_year_purchases_dropped_count = this_year_purchases_dropped.count()
- this_year_purchases_dropped_percentage = int(
- safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count)
- * 100
- )
- context = {
- "total_hours": format_duration(
- this_year_sessions.total_duration_unformatted(), "%2.0H"
- ),
- "total_year_games": this_year_played_purchases.all().count(),
- "top_10_games_by_playtime": top_10_games_by_playtime,
- "year": year,
- "total_playtime_per_platform": total_playtime_per_platform,
- "total_spent": total_spent,
- "total_spent_currency": selected_currency,
- "spent_per_game": int(
- safe_division(total_spent, this_year_purchases_without_refunded_count)
- ),
- "this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
- "total_sessions": this_year_sessions.count(),
- "unique_days": unique_days["dates"],
- "unique_days_percent": int(unique_days["dates"] / 365 * 100),
- "purchased_unfinished_count": this_year_purchases_unfinished_count,
- "unfinished_purchases_percent": this_year_purchases_unfinished_percent,
- "dropped_count": this_year_purchases_dropped_count,
- "dropped_percentage": this_year_purchases_dropped_percentage,
- "refunded_percent": int(
- safe_division(
- all_purchased_refunded_this_year_count,
- all_purchased_this_year_count,
- )
- * 100
- ),
- "all_purchased_refunded_this_year": this_year_purchases_refunded,
- "all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
- "all_purchased_this_year_count": all_purchased_this_year_count,
- "backlog_decrease_count": backlog_decrease_count,
- "longest_session_time": (
- format_duration(longest_session.duration, "%2.0Hh %2.0mm")
- if longest_session
- else 0
- ),
- "longest_session_game": (longest_session.game if longest_session else None),
- "highest_session_count": (
- game_highest_session_count.session_count
- if game_highest_session_count
- else 0
- ),
- "highest_session_count_game": (
- game_highest_session_count if game_highest_session_count else None
- ),
- "highest_session_average": (
- format_duration(
- highest_session_average_game.session_average, "%2.0Hh %2.0mm"
- )
- if highest_session_average_game
- else 0
- ),
- "highest_session_average_game": highest_session_average_game,
- "first_play_game": first_play_game,
- "first_play_date": first_play_date,
- "last_play_game": last_play_game,
- "last_play_date": last_play_date,
- "title": f"{year} Stats",
- "stats_dropdown_year_range": available_stats_year_range(),
- }
-
request.session["return_path"] = request.path
- return render_page(request, stats_content(context), title=context["title"])
+ data = compute_stats(None)
+ return render_page(request, stats_content(data), title=data["title"])
@login_required
@@ -290,262 +79,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
)
if year == 0:
return HttpResponseRedirect(reverse("games:stats_alltime"))
- this_year_sessions = Session.objects.filter(
- timestamp_start__year=year
- ).prefetch_related("game")
- this_year_sessions_with_durations = this_year_sessions.annotate(
- duration=ExpressionWrapper(
- F("timestamp_end") - F("timestamp_start"),
- output_field=fields.DurationField(),
- )
- )
- longest_session = this_year_sessions_with_durations.order_by("-duration").first()
- this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct()
- this_year_games_with_session_counts = this_year_games.annotate(
- session_count=Count(
- "sessions",
- filter=Q(sessions__timestamp_start__year=year),
- )
- )
- game_highest_session_count = this_year_games_with_session_counts.order_by(
- "-session_count"
- ).first()
- selected_currency = "CZK"
- unique_days = (
- this_year_sessions.annotate(date=TruncDate("timestamp_start"))
- .values("date")
- .distinct()
- .aggregate(dates=Count("date"))
- )
- this_year_played_purchases = Purchase.objects.filter(
- games__sessions__in=this_year_sessions
- ).distinct()
-
- this_year_played_games = Game.objects.filter(
- sessions__in=this_year_sessions
- ).distinct()
-
- this_year_purchases = Purchase.objects.filter(
- date_purchased__year=year
- ).prefetch_related("games")
- # purchased this year
- # not refunded
- this_year_purchases_without_refunded = Purchase.objects.filter(
- date_refunded=None, date_purchased__year=year
- )
-
- # purchased this year
- # not refunded
- # not finished
- # not infinite
- # only Game and DLC
- this_year_purchases_unfinished_dropped_nondropped = (
- this_year_purchases_without_refunded.filter(
- ~Q(games__status=Game.Status.FINISHED)
- & ~Q(games__playevents__ended__year=year)
- )
- .filter(infinite=False)
- .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
- )
-
- # unfinished = not finished AND not dropped
- this_year_purchases_unfinished = (
- this_year_purchases_unfinished_dropped_nondropped.filter(
- ~Q(games__status=Game.Status.RETIRED)
- & ~Q(games__status=Game.Status.ABANDONED)
- )
- )
- # dropped = abandoned OR retired OR refunded (OR logic for transition)
- this_year_purchases_dropped = (
- this_year_purchases.filter(
- ~Q(games__status=Game.Status.FINISHED)
- & ~Q(games__playevents__ended__year=year)
- )
- .filter(Q(games__status=Game.Status.ABANDONED) | Q(date_refunded__isnull=False))
- .filter(infinite=False)
- .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
- )
-
- this_year_purchases_without_refunded_count = (
- this_year_purchases_without_refunded.count()
- )
- this_year_purchases_unfinished_count = this_year_purchases_unfinished.count()
- this_year_purchases_unfinished_percent = int(
- safe_division(
- this_year_purchases_unfinished_count,
- this_year_purchases_without_refunded_count,
- )
- * 100
- )
-
- purchases_finished_this_year = (
- Purchase.objects.finished()
- .filter(games__playevents__ended__year=year)
- .annotate(
- game_name=F("games__name"), date_finished=F("games__playevents__ended")
- )
- )
- purchases_finished_this_year_released_this_year = (
- purchases_finished_this_year.filter(games__year_released=year).order_by(
- "games__playevents__ended"
- )
- )
- purchased_this_year_finished_this_year = (
- this_year_purchases_without_refunded.filter(
- games__playevents__ended__year=year
- ).annotate(
- game_name=F("games__name"), date_finished=F("games__playevents__ended")
- )
- ).order_by("games__playevents__ended")
-
- this_year_spendings = this_year_purchases_without_refunded.aggregate(
- total_spent=Sum(F("converted_price"))
- )
- total_spent = this_year_spendings["total_spent"] or 0
-
- games_with_playtime = (
- Game.objects.filter(sessions__timestamp_start__year=year)
- .annotate(
- total_playtime=Sum(
- F("sessions__duration_calculated"),
- )
- )
- .filter(total_playtime__gt=timedelta(0))
- )
-
- month_playtimes = (
- this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
- .values("month")
- .annotate(playtime=Sum("duration_total"))
- .order_by("month")
- )
-
- highest_session_average_game = (
- Game.objects.filter(sessions__in=this_year_sessions)
- .annotate(session_average=Avg("sessions__duration_calculated"))
- .order_by("-session_average")
- .first()
- )
- top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")
-
- total_playtime_per_platform = (
- this_year_sessions.values("game__platform__name")
- .annotate(playtime=Sum(F("duration_total")))
- .annotate(platform_name=F("game__platform__name"))
- .values("platform_name", "playtime")
- .order_by("-playtime")
- )
-
- backlog_decrease_count = (
- Purchase.objects.filter(date_purchased__year__lt=year)
- .filter(games__status=Game.Status.FINISHED)
- .filter(games__playevents__ended__year=year)
- .count()
- )
-
- first_play_date = "N/A"
- last_play_date = "N/A"
- first_play_game = None
- last_play_game = None
- if this_year_sessions:
- first_session = this_year_sessions.earliest()
- first_play_game = first_session.game
- first_play_date = first_session.timestamp_start.strftime(dateformat)
- last_session = this_year_sessions.latest()
- last_play_game = last_session.game
- last_play_date = last_session.timestamp_start.strftime(dateformat)
-
- all_purchased_this_year_count = this_year_purchases.count()
- this_year_purchases_refunded = Purchase.objects.exclude(date_refunded=None).filter(
- date_purchased__year=year
- )
- all_purchased_refunded_this_year_count = this_year_purchases_refunded.count()
-
- this_year_purchases_dropped_count = this_year_purchases_dropped.count()
- this_year_purchases_dropped_percentage = int(
- safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count)
- * 100
- )
- context = {
- "total_hours": format_duration(
- this_year_sessions.total_duration_unformatted(), "%2.0H"
- ),
- "total_games": this_year_played_games.count(),
- "total_year_games": this_year_played_purchases.filter(
- games__year_released=year
- ).count(),
- "top_10_games_by_playtime": top_10_games_by_playtime,
- "year": year,
- "total_playtime_per_platform": total_playtime_per_platform,
- "total_spent": total_spent,
- "total_spent_currency": selected_currency,
- "spent_per_game": int(
- safe_division(total_spent, this_year_purchases_without_refunded_count)
- ),
- "all_finished_this_year": purchases_finished_this_year.prefetch_related(
- "games"
- ).order_by("games__playevents__ended"),
- "all_finished_this_year_count": purchases_finished_this_year.count(),
- "this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related(
- "games"
- ).order_by("games__playevents__ended"),
- "this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
- "purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related(
- "games"
- ).order_by("games__playevents__ended"),
- "total_sessions": this_year_sessions.count(),
- "unique_days": unique_days["dates"],
- "unique_days_percent": int(unique_days["dates"] / 365 * 100),
- "purchased_unfinished": this_year_purchases_unfinished,
- "purchased_unfinished_count": this_year_purchases_unfinished_count,
- "unfinished_purchases_percent": this_year_purchases_unfinished_percent,
- "dropped_count": this_year_purchases_dropped_count,
- "dropped_percentage": this_year_purchases_dropped_percentage,
- "refunded_percent": int(
- safe_division(
- all_purchased_refunded_this_year_count,
- all_purchased_this_year_count,
- )
- * 100
- ),
- "all_purchased_refunded_this_year": this_year_purchases_refunded,
- "all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
- "all_purchased_this_year": this_year_purchases.order_by("date_purchased"),
- "all_purchased_this_year_count": all_purchased_this_year_count,
- "backlog_decrease_count": backlog_decrease_count,
- "longest_session_time": (
- format_duration(longest_session.duration, "%2.0Hh %2.0mm")
- if longest_session
- else 0
- ),
- "longest_session_game": (longest_session.game if longest_session else None),
- "highest_session_count": (
- game_highest_session_count.session_count
- if game_highest_session_count
- else 0
- ),
- "highest_session_count_game": (
- game_highest_session_count if game_highest_session_count else None
- ),
- "highest_session_average": (
- format_duration(
- highest_session_average_game.session_average, "%2.0Hh %2.0mm"
- )
- if highest_session_average_game
- else 0
- ),
- "highest_session_average_game": highest_session_average_game,
- "first_play_game": first_play_game,
- "first_play_date": first_play_date,
- "last_play_game": last_play_game,
- "last_play_date": last_play_date,
- "title": f"{year} Stats",
- "month_playtimes": month_playtimes,
- "stats_dropdown_year_range": available_stats_year_range(),
- }
-
request.session["return_path"] = request.path
- return render_page(request, stats_content(context), title=context["title"])
+ data = compute_stats(year)
+ return render_page(request, stats_content(data), title=data["title"])
@login_required
diff --git a/games/views/stats_data.py b/games/views/stats_data.py
new file mode 100644
index 0000000..4a408f8
--- /dev/null
+++ b/games/views/stats_data.py
@@ -0,0 +1,351 @@
+"""Request-free stats computation: the data half of the stats page.
+
+`compute_stats(year)` returns a `StatsData` dict (the documented seam between
+*computing* metrics and *rendering* them in `stats_content`). Today it computes
+from the ORM; this is also the function a future materialization job would call,
+and the shape it would populate from a pre-calculated table.
+
+`year=None` means all-time; otherwise the metrics are scoped to that calendar
+year. The two scopes genuinely diverge (different aggregations, and all-time
+hides the per-purchase list sections), so the differences are kept explicit.
+"""
+
+from datetime import date, timedelta
+from typing import Any, NotRequired, TypedDict
+
+from django.db.models import (
+ Avg,
+ Count,
+ ExpressionWrapper,
+ F,
+ Max,
+ OuterRef,
+ Q,
+ Subquery,
+ Sum,
+ fields,
+)
+from django.db.models.functions import TruncDate, TruncMonth
+
+from common.time import available_stats_year_range, dateformat, format_duration
+from common.utils import safe_division
+from games.models import Game, Purchase, Session
+
+
+class StatsData(TypedDict):
+ # --- always present (both scopes) ---
+ year: Any # int for a year, "Alltime" for all-time
+ title: str
+ total_hours: str
+ total_sessions: int
+ unique_days: int
+ unique_days_percent: int
+ total_year_games: int
+ this_year_finished_this_year_count: int
+ top_10_games_by_playtime: Any
+ total_playtime_per_platform: Any
+ total_spent: Any
+ total_spent_currency: str
+ spent_per_game: int
+ all_purchased_this_year_count: int
+ all_purchased_refunded_this_year: Any
+ all_purchased_refunded_this_year_count: int
+ refunded_percent: int
+ dropped_count: int
+ dropped_percentage: int
+ purchased_unfinished_count: int
+ unfinished_purchases_percent: int
+ backlog_decrease_count: int
+ longest_session_time: Any
+ longest_session_game: Any
+ highest_session_count: int
+ highest_session_count_game: Any
+ highest_session_average: Any
+ highest_session_average_game: Any
+ first_play_game: Any
+ first_play_date: str
+ last_play_game: Any
+ last_play_date: str
+ stats_dropdown_year_range: Any
+ # --- per-year only (omitted for all-time, which hides these sections) ---
+ total_games: NotRequired[int]
+ month_playtimes: NotRequired[Any]
+ all_finished_this_year: NotRequired[Any]
+ all_finished_this_year_count: NotRequired[int]
+ this_year_finished_this_year: NotRequired[Any]
+ purchased_this_year_finished_this_year: NotRequired[Any]
+ purchased_unfinished: NotRequired[Any]
+ all_purchased_this_year: NotRequired[Any]
+
+
+def _days_played_percent(unique_days: int, first: date, last: date) -> int:
+ """Share of days played across the span actually played (all-time).
+
+ Unlike the per-year metric (``unique_days / 365``), the all-time span is the
+ real number of days between the first and last session, so the result stays
+ meaningful (and ≤100%) across multiple years.
+ """
+ span = (last - first).days + 1
+ if span <= 0:
+ return 0
+ return min(int(unique_days / span * 100), 100)
+
+
+def compute_stats(year: int | None = None) -> StatsData:
+ is_alltime = year is None
+ currency = "CZK"
+
+ # ── Scope ──────────────────────────────────────────────────────────────
+ if is_alltime:
+ sessions = Session.objects.all().prefetch_related("game")
+ purchases = Purchase.objects.all()
+ without_refunded = Purchase.objects.filter(date_refunded=None)
+ refunded = Purchase.objects.refunded()
+ ended_q = Q(games__playevents__ended__isnull=False)
+ session_count = Count("sessions")
+ else:
+ sessions = Session.objects.filter(timestamp_start__year=year).prefetch_related(
+ "game"
+ )
+ purchases = Purchase.objects.filter(date_purchased__year=year)
+ without_refunded = Purchase.objects.filter(
+ date_refunded=None, date_purchased__year=year
+ )
+ refunded = Purchase.objects.exclude(date_refunded=None).filter(
+ date_purchased__year=year
+ )
+ ended_q = Q(games__playevents__ended__year=year)
+ session_count = Count(
+ "sessions", filter=Q(sessions__timestamp_start__year=year)
+ )
+
+ not_finished_q = ~Q(games__status=Game.Status.FINISHED) & ~ended_q
+
+ # ── Session superlatives ─────────────────────────────────────────────────
+ longest_session = (
+ sessions.annotate(
+ duration=ExpressionWrapper(
+ F("timestamp_end") - F("timestamp_start"),
+ output_field=fields.DurationField(),
+ )
+ )
+ .order_by("-duration")
+ .first()
+ )
+ games_in_scope = Game.objects.filter(sessions__in=sessions).distinct()
+ highest_session_count_game = (
+ games_in_scope.annotate(session_count=session_count)
+ .order_by("-session_count")
+ .first()
+ )
+ highest_session_average_game = (
+ Game.objects.filter(sessions__in=sessions)
+ .annotate(session_average=Avg("sessions__duration_calculated"))
+ .order_by("-session_average")
+ .first()
+ )
+
+ # ── Days played + play range ─────────────────────────────────────────────
+ unique_days = (
+ sessions.annotate(date=TruncDate("timestamp_start"))
+ .values("date")
+ .distinct()
+ .aggregate(dates=Count("date"))["dates"]
+ )
+ first_session = sessions.earliest() if sessions.exists() else None
+ last_session = sessions.latest() if sessions.exists() else None
+ first_play_game = first_session.game if first_session else None
+ last_play_game = last_session.game if last_session else None
+ first_play_date = (
+ first_session.timestamp_start.strftime(dateformat) if first_session else "N/A"
+ )
+ last_play_date = (
+ last_session.timestamp_start.strftime(dateformat) if last_session else "N/A"
+ )
+ if is_alltime:
+ unique_days_percent = (
+ _days_played_percent(
+ unique_days,
+ first_session.timestamp_start.date(),
+ last_session.timestamp_start.date(),
+ )
+ if first_session
+ else 0
+ )
+ else:
+ unique_days_percent = int(unique_days / 365 * 100)
+
+ # ── Spending ─────────────────────────────────────────────────────────────
+ total_spent = without_refunded.aggregate(total=Sum(F("converted_price")))["total"] or 0
+ without_refunded_count = without_refunded.count()
+
+ # ── Purchase breakdown ───────────────────────────────────────────────────
+ only_games_and_dlc = Q(type=Purchase.GAME) | Q(type=Purchase.DLC)
+ unfinished = (
+ without_refunded.filter(not_finished_q)
+ .filter(infinite=False)
+ .filter(only_games_and_dlc)
+ .filter(~Q(games__status=Game.Status.RETIRED) & ~Q(games__status=Game.Status.ABANDONED))
+ )
+ dropped = (
+ purchases.filter(not_finished_q)
+ .filter(Q(games__status=Game.Status.ABANDONED) | Q(date_refunded__isnull=False))
+ .filter(infinite=False)
+ .filter(only_games_and_dlc)
+ )
+ unfinished_count = unfinished.count()
+ dropped_count = dropped.count()
+ all_purchased_count = purchases.count()
+ refunded_count = refunded.count()
+
+ # ── Finished purchases (scope-divergent) ─────────────────────────────────
+ if is_alltime:
+ finished = Purchase.objects.finished().annotate(
+ date_finished=Subquery(
+ Purchase.objects.filter(pk=OuterRef("pk"))
+ .annotate(max_ended=Max("games__playevents__ended"))
+ .values("max_ended")[:1]
+ )
+ )
+ finished_released = finished.order_by("-date_finished")
+ backlog_decrease_count = finished.count()
+ else:
+ finished = (
+ Purchase.objects.finished()
+ .filter(games__playevents__ended__year=year)
+ .annotate(
+ game_name=F("games__name"), date_finished=F("games__playevents__ended")
+ )
+ )
+ finished_released = finished.filter(games__year_released=year).order_by(
+ "games__playevents__ended"
+ )
+ purchased_finished = (
+ without_refunded.filter(games__playevents__ended__year=year)
+ .annotate(
+ game_name=F("games__name"), date_finished=F("games__playevents__ended")
+ )
+ .order_by("games__playevents__ended")
+ )
+ backlog_decrease_count = (
+ Purchase.objects.filter(date_purchased__year__lt=year)
+ .filter(games__status=Game.Status.FINISHED)
+ .filter(games__playevents__ended__year=year)
+ .count()
+ )
+
+ # ── Games / platforms by playtime (unified on duration_total) ────────────
+ if is_alltime:
+ games_with_playtime = (
+ Game.objects.filter(sessions__in=sessions)
+ .distinct()
+ .annotate(total_playtime=Sum("sessions__duration_total"))
+ .filter(total_playtime__gt=timedelta(0))
+ )
+ top_games = games_with_playtime.order_by("-total_playtime")[:10]
+ else:
+ games_with_playtime = (
+ Game.objects.filter(sessions__timestamp_start__year=year)
+ .annotate(total_playtime=Sum("sessions__duration_total"))
+ .filter(total_playtime__gt=timedelta(0))
+ )
+ top_games = games_with_playtime.order_by("-total_playtime")
+
+ total_playtime_per_platform = (
+ sessions.values("game__platform__name")
+ .annotate(playtime=Sum(F("duration_total")))
+ .annotate(platform_name=F("game__platform__name"))
+ .values("platform_name", "playtime")
+ .order_by("-playtime")
+ )
+
+ played_purchases = Purchase.objects.filter(games__sessions__in=sessions).distinct()
+ total_year_games = (
+ played_purchases.count()
+ if is_alltime
+ else played_purchases.filter(games__year_released=year).count()
+ )
+
+ year_label = "Alltime" if is_alltime else year
+ data: StatsData = {
+ "year": year_label,
+ "title": f"{year_label} Stats",
+ "total_hours": format_duration(
+ sessions.total_duration_unformatted(), "%2.0H"
+ ),
+ "total_sessions": sessions.count(),
+ "unique_days": unique_days,
+ "unique_days_percent": unique_days_percent,
+ "total_year_games": total_year_games,
+ "this_year_finished_this_year_count": finished_released.count(),
+ "top_10_games_by_playtime": top_games,
+ "total_playtime_per_platform": total_playtime_per_platform,
+ "total_spent": total_spent,
+ "total_spent_currency": currency,
+ "spent_per_game": int(safe_division(total_spent, without_refunded_count)),
+ "all_purchased_this_year_count": all_purchased_count,
+ "all_purchased_refunded_this_year": refunded,
+ "all_purchased_refunded_this_year_count": refunded_count,
+ "refunded_percent": int(
+ safe_division(refunded_count, all_purchased_count) * 100
+ ),
+ "dropped_count": dropped_count,
+ "dropped_percentage": int(
+ safe_division(dropped_count, all_purchased_count) * 100
+ ),
+ "purchased_unfinished_count": unfinished_count,
+ "unfinished_purchases_percent": int(
+ safe_division(unfinished_count, without_refunded_count) * 100
+ ),
+ "backlog_decrease_count": backlog_decrease_count,
+ "longest_session_time": (
+ format_duration(longest_session.duration, "%2.0Hh %2.0mm")
+ if longest_session
+ else 0
+ ),
+ "longest_session_game": longest_session.game if longest_session else None,
+ "highest_session_count": (
+ highest_session_count_game.session_count
+ if highest_session_count_game
+ else 0
+ ),
+ "highest_session_count_game": highest_session_count_game,
+ "highest_session_average": (
+ format_duration(
+ highest_session_average_game.session_average, "%2.0Hh %2.0mm"
+ )
+ if highest_session_average_game
+ else 0
+ ),
+ "highest_session_average_game": highest_session_average_game,
+ "first_play_game": first_play_game,
+ "first_play_date": first_play_date,
+ "last_play_game": last_play_game,
+ "last_play_date": last_play_date,
+ "stats_dropdown_year_range": available_stats_year_range(),
+ }
+
+ if not is_alltime:
+ data["total_games"] = games_in_scope.count()
+ data["month_playtimes"] = (
+ sessions.annotate(month=TruncMonth("timestamp_start"))
+ .values("month")
+ .annotate(playtime=Sum("duration_total"))
+ .order_by("month")
+ )
+ data["all_finished_this_year"] = finished.prefetch_related("games").order_by(
+ "games__playevents__ended"
+ )
+ data["all_finished_this_year_count"] = finished.count()
+ data["this_year_finished_this_year"] = finished_released.prefetch_related(
+ "games"
+ ).order_by("games__playevents__ended")
+ data["purchased_this_year_finished_this_year"] = (
+ purchased_finished.prefetch_related("games").order_by(
+ "games__playevents__ended"
+ )
+ )
+ data["purchased_unfinished"] = unfinished
+ data["all_purchased_this_year"] = purchases.order_by("date_purchased")
+
+ return data
diff --git a/tests/test_stats.py b/tests/test_stats.py
new file mode 100644
index 0000000..befd427
--- /dev/null
+++ b/tests/test_stats.py
@@ -0,0 +1,112 @@
+"""Behaviour tests for the stats provider (compute_stats).
+
+Locks the metrics that must not change in the view-unification refactor, and
+pins the two intentional fixes: all-time "days played %" is span-based, and
+games-by-playtime uses duration_total (so manual sessions count).
+"""
+
+from datetime import datetime, timedelta
+from zoneinfo import ZoneInfo
+
+from django.conf import settings
+from django.test import TestCase
+
+from games.models import Game, Platform, Session
+from games.views.stats_data import _days_played_percent, compute_stats
+
+TZ = ZoneInfo(settings.TIME_ZONE)
+
+
+class DaysPlayedPercentTest(TestCase):
+ """The span-based all-time percent must differ from the old /365."""
+
+ def test_span_based_differs_from_per_year(self):
+ first = datetime(2021, 1, 1).date()
+ last = datetime(2023, 12, 31).date() # ~1095-day span
+ # 100 unique days over a 3-year span = ~9%, not the old 100/365 = 27%.
+ self.assertEqual(_days_played_percent(100, first, last), 9)
+
+ def test_capped_at_100_and_safe_on_empty_span(self):
+ d = datetime(2023, 1, 1).date()
+ self.assertEqual(_days_played_percent(5, d, d), 100) # 1-day span
+ self.assertEqual(_days_played_percent(0, d, d), 0)
+
+
+class ComputeStatsTest(TestCase):
+ def setUp(self):
+ self.platform = Platform.objects.create(name="PC", icon="pc")
+ self.game_a = Game.objects.create(
+ name="Game A", platform=self.platform, year_released=2022
+ )
+ self.game_b = Game.objects.create(
+ name="Game B", platform=self.platform, year_released=2023
+ )
+
+ def dt(y, mo, d, h, mi=0):
+ return datetime(y, mo, d, h, mi, tzinfo=TZ)
+
+ # Game A in 2023: 1h + 1.5h on the same day = 2.5h
+ Session.objects.create(
+ game=self.game_a, timestamp_start=dt(2023, 6, 10, 10), timestamp_end=dt(2023, 6, 10, 11)
+ )
+ Session.objects.create(
+ game=self.game_a, timestamp_start=dt(2023, 6, 10, 14), timestamp_end=dt(2023, 6, 10, 15, 30)
+ )
+ # Game B in 2023: 1h tracked + 2h manual (no end) = 3h total
+ Session.objects.create(
+ game=self.game_b, timestamp_start=dt(2023, 7, 1, 20), timestamp_end=dt(2023, 7, 1, 21)
+ )
+ Session.objects.create(
+ game=self.game_b,
+ timestamp_start=dt(2023, 7, 2, 12),
+ duration_manual=timedelta(hours=2),
+ )
+ # Game A in 2022 (only counts toward all-time): 2h
+ Session.objects.create(
+ game=self.game_a, timestamp_start=dt(2022, 5, 1, 10), timestamp_end=dt(2022, 5, 1, 12)
+ )
+
+ # ── shared metrics (characterization) ──
+
+ def test_session_and_day_counts(self):
+ year = compute_stats(2023)
+ alltime = compute_stats(None)
+ self.assertEqual(year["total_sessions"], 4)
+ self.assertEqual(alltime["total_sessions"], 5)
+ self.assertEqual(year["unique_days"], 3) # 06-10, 07-01, 07-02
+ self.assertEqual(alltime["unique_days"], 4) # + 2022-05-01
+
+ def test_per_year_percent_is_over_365(self):
+ self.assertEqual(compute_stats(2023)["unique_days_percent"], int(3 / 365 * 100))
+
+ def test_alltime_percent_is_span_based_and_sane(self):
+ pct = compute_stats(None)["unique_days_percent"]
+ self.assertGreaterEqual(pct, 0)
+ self.assertLessEqual(pct, 100)
+
+ # ── the duration_total fix ──
+
+ def test_games_by_playtime_includes_manual_sessions(self):
+ """In 2023, Game B's manual 2h must count, putting it (3h) above A (2.5h)."""
+ top = list(compute_stats(2023)["top_10_games_by_playtime"])
+ self.assertEqual(top[0].id, self.game_b.id)
+ self.assertEqual(top[0].total_playtime, timedelta(hours=3))
+
+ def test_alltime_playtime_sums_all_years(self):
+ """All-time Game A = 2.5h (2023) + 2h (2022) = 4.5h, ahead of B (3h)."""
+ top = list(compute_stats(None)["top_10_games_by_playtime"])
+ self.assertEqual(top[0].id, self.game_a.id)
+ self.assertEqual(top[0].total_playtime, timedelta(hours=4, minutes=30))
+
+ # ── section visibility (scope difference preserved) ──
+
+ def test_alltime_omits_per_year_list_sections(self):
+ alltime = compute_stats(None)
+ year = compute_stats(2023)
+ for key in ("month_playtimes", "all_purchased_this_year", "total_games"):
+ self.assertNotIn(key, alltime)
+ self.assertIn(key, year)
+
+ def test_year_label(self):
+ self.assertEqual(compute_stats(None)["year"], "Alltime")
+ self.assertEqual(compute_stats(2023)["year"], 2023)