Use hours instead of minutes for playtime filters
Django CI/CD / test (push) Failing after 1m13s
Django CI/CD / build-and-push (push) Has been skipped

This commit is contained in:
2026-06-10 21:59:56 +02:00
parent d021c280d2
commit 7e299d84fd
6 changed files with 98 additions and 124 deletions
+36 -50
View File
@@ -759,10 +759,10 @@ def FilterBar(
existing, "original_year_released"
)
mastered_value = _parse_bool_nullable(existing, "mastered")
playtime = existing.get("playtime_minutes", {})
playtime = existing.get("playtime_hours", {})
if isinstance(playtime, dict):
playtime_min = _filter_mins_to_hrs(playtime.get("value", ""))
playtime_max = _filter_mins_to_hrs(playtime.get("value2", ""))
playtime_min = playtime.get("value", "")
playtime_max = playtime.get("value2", "")
else:
playtime_min = ""
playtime_max = ""
@@ -771,8 +771,8 @@ def FilterBar(
session_avg_min, session_avg_max = _parse_range(existing, "session_average")
purchase_count_min, purchase_count_max = _parse_range(existing, "purchase_count")
playevent_count_min, playevent_count_max = _parse_range(existing, "playevent_count")
manual_pt_min, manual_pt_max = _parse_range(existing, "manual_playtime_minutes")
calc_pt_min, calc_pt_max = _parse_range(existing, "calculated_playtime_minutes")
manual_pt_min, manual_pt_max = _parse_range(existing, "manual_playtime_hours")
calc_pt_min, calc_pt_max = _parse_range(existing, "calculated_playtime_hours")
price_total_min, price_total_max = _parse_range(existing, "purchase_price_total")
price_any_min, price_any_max = _parse_range(existing, "purchase_price_any")
purchase_refunded_value = _parse_bool_nullable(existing, "purchase_refunded")
@@ -916,7 +916,7 @@ def FilterBar(
"Total playtime",
RangeSlider(
label="Total playtime",
input_name_prefix="filter-playtime",
input_name_prefix="filter-playtime-hours",
min_value=playtime_min,
max_value=playtime_max,
range_min=0,
@@ -927,45 +927,31 @@ def FilterBar(
),
),
_filter_field(
"Manual Playtime (mins)",
"Manual Playtime (hrs)",
RangeSlider(
label="Manual Playtime (mins)",
input_name_prefix="filter-manual-playtime-minutes",
label="Manual Playtime (hrs)",
input_name_prefix="filter-manual-playtime-hours",
min_value=manual_pt_min,
max_value=manual_pt_max,
range_min=0,
range_max=max(playtime_range_max * 60, 240),
range_max=max(playtime_range_max, 4),
step="1",
min_placeholder="e.g. 10",
max_placeholder="e.g. 120",
min_placeholder="e.g. 1",
max_placeholder="e.g. 10",
),
),
_filter_field(
"Calculated Playtime (mins)",
"Calculated Playtime (hrs)",
RangeSlider(
label="Calculated Playtime (mins)",
input_name_prefix="filter-calculated-playtime-minutes",
label="Calculated Playtime (hrs)",
input_name_prefix="filter-calculated-playtime-hours",
min_value=calc_pt_min,
max_value=calc_pt_max,
range_min=0,
range_max=max(playtime_range_max * 60, 240),
range_max=max(playtime_range_max, 4),
step="1",
min_placeholder="e.g. 30",
max_placeholder="e.g. 120",
),
),
_filter_field(
"Calculated Playtime (mins)",
RangeSlider(
label="Calculated Playtime (mins)",
input_name_prefix="filter-calculated-playtime-minutes",
min_value=calc_pt_min,
max_value=calc_pt_max,
range_min=0,
range_max=max(playtime_range_max * 60, 240),
step="1",
min_placeholder="e.g. 30",
max_placeholder="e.g. 180",
min_placeholder="e.g. 1",
max_placeholder="e.g. 10",
),
),
_filter_field(
@@ -1090,9 +1076,9 @@ def SessionFilterBar(
note_value = existing.get("note", {}).get("value", "")
note_modifier = existing.get("note", {}).get("modifier", "EQUALS")
dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_minutes")
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_minutes")
dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_minutes")
dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_hours")
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_hours")
dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_hours")
emulated_value = _parse_bool_nullable(existing, "emulated")
is_active_value = _parse_bool_nullable(existing, "is_active")
try:
@@ -1142,37 +1128,37 @@ def SessionFilterBar(
],
),
RangeSlider(
label="Total Duration (mins)",
input_name_prefix="filter-duration-total-minutes",
label="Total Duration (hrs)",
input_name_prefix="filter-duration-total-hours",
min_value=dur_tot_min,
max_value=dur_tot_max,
range_min=0,
range_max=duration_range_max * 60, # Range sliders use minutes now
range_max=duration_range_max,
step="1",
min_placeholder="e.g. 30",
max_placeholder="e.g. 180",
min_placeholder="e.g. 1",
max_placeholder="e.g. 10",
),
RangeSlider(
label="Manual Duration (mins)",
input_name_prefix="filter-duration-manual-minutes",
label="Manual Duration (hrs)",
input_name_prefix="filter-duration-manual-hours",
min_value=dur_man_min,
max_value=dur_man_max,
range_min=0,
range_max=240,
range_max=duration_range_max,
step="1",
min_placeholder="e.g. 10",
max_placeholder="e.g. 120",
min_placeholder="e.g. 1",
max_placeholder="e.g. 10",
),
RangeSlider(
label="Calculated Duration (mins)",
input_name_prefix="filter-duration-calculated-minutes",
label="Calculated Duration (hrs)",
input_name_prefix="filter-duration-calculated-hours",
min_value=dur_calc_min,
max_value=dur_calc_max,
range_min=0,
range_max=duration_range_max * 60,
range_max=duration_range_max,
step="1",
min_placeholder="e.g. 30",
max_placeholder="e.g. 180",
min_placeholder="e.g. 1",
max_placeholder="e.g. 10",
),
Div(
attributes=[("class", "flex gap-6 mb-4")],
+39 -39
View File
@@ -62,18 +62,18 @@ class GameFilter(OperatorFilter):
platform_group: MultiCriterion | None = None # platform__group__in
status: ChoiceCriterion | None = None # selectable filter widget
mastered: BoolCriterion | None = None
playtime_minutes: IntCriterion | None = None # converted to timedelta on to_q()
playtime_hours: IntCriterion | None = None # converted to timedelta on to_q()
created_at: StringCriterion | None = None # date string
updated_at: StringCriterion | None = None # date string
session_count: IntCriterion | None = None
session_average: IntCriterion | None = None # average in minutes
session_average: IntCriterion | None = None # average in hours
purchase_count: IntCriterion | None = None # distinct purchases per game
playevent_count: IntCriterion | None = None # playevents per game
# Aggregate session durations (minutes), summed across the game's sessions
manual_playtime_minutes: IntCriterion | None = None
calculated_playtime_minutes: IntCriterion | None = None
# Aggregate session durations (hours), summed across the game's sessions
manual_playtime_hours: IntCriterion | None = None
calculated_playtime_hours: IntCriterion | None = None
# Cross-entity: any session played on these devices / matching these flags
device: MultiCriterion | None = None # game has session on any of these devices
@@ -119,8 +119,8 @@ class GameFilter(OperatorFilter):
q &= self.status.to_q("status")
if self.mastered is not None:
q &= self.mastered.to_q("mastered")
if self.playtime_minutes is not None:
q &= self._playtime_to_q(self.playtime_minutes)
if self.playtime_hours is not None:
q &= self._playtime_to_q(self.playtime_hours)
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
if self.updated_at is not None:
@@ -177,7 +177,7 @@ class GameFilter(OperatorFilter):
)
q &= Q(id__in=matching_ids)
if self.manual_playtime_minutes is not None:
if self.manual_playtime_hours is not None:
from django.db.models import Sum
from games.models import Game
@@ -186,14 +186,14 @@ class GameFilter(OperatorFilter):
Game.objects.annotate(s_manual=Sum("sessions__duration_manual"))
.filter(
self._playtime_to_q_for_field(
self.manual_playtime_minutes, "s_manual"
self.manual_playtime_hours, "s_manual"
)
)
.values_list("id", flat=True)
)
q &= Q(id__in=matching_ids)
if self.calculated_playtime_minutes is not None:
if self.calculated_playtime_hours is not None:
from django.db.models import Sum
from games.models import Game
@@ -202,7 +202,7 @@ class GameFilter(OperatorFilter):
Game.objects.annotate(s_calc=Sum("sessions__duration_calculated"))
.filter(
self._playtime_to_q_for_field(
self.calculated_playtime_minutes, "s_calc"
self.calculated_playtime_hours, "s_calc"
)
)
.values_list("id", flat=True)
@@ -362,30 +362,30 @@ class GameFilter(OperatorFilter):
@staticmethod
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
"""Convert minutes-based criterion to a DurationField Q object.
"""Convert hours-based criterion to a DurationField Q object.
Django stores DurationField as microseconds in SQLite, so we convert
minutes → timedelta(microseconds=X) and use the appropriate lookups.
hours → timedelta(microseconds=X) and use the appropriate lookups.
"""
from datetime import timedelta
from common.criteria import Modifier
m = c.modifier
td_val = timedelta(minutes=c.value)
td_val = timedelta(hours=c.value)
if m == Modifier.EQUALS:
return Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
f"{field}__lt": timedelta(hours=c.value + 1),
}
)
if m == Modifier.NOT_EQUALS:
return ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
f"{field}__lt": timedelta(hours=c.value + 1),
}
)
if m == Modifier.GREATER_THAN:
@@ -393,12 +393,12 @@ class GameFilter(OperatorFilter):
if m == Modifier.LESS_THAN:
return Q(**{f"{field}__lt": td_val})
if m == Modifier.BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
lo = timedelta(hours=min(c.value, c.value2))
hi = timedelta(hours=max(c.value, c.value2))
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
if m == Modifier.NOT_BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
lo = timedelta(hours=min(c.value, c.value2))
hi = timedelta(hours=max(c.value, c.value2))
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
if m == Modifier.IS_NULL:
return Q(**{f"{field}": timedelta(0)})
@@ -431,10 +431,10 @@ class SessionFilter(OperatorFilter):
device: MultiCriterion | None = None # filters on device_id
emulated: BoolCriterion | None = None
note: StringCriterion | None = None
duration_minutes: IntCriterion | None = None # on duration_total (legacy alias)
duration_total_minutes: IntCriterion | None = None
duration_manual_minutes: IntCriterion | None = None
duration_calculated_minutes: IntCriterion | None = None
duration_hours: IntCriterion | None = None # on duration_total (legacy alias)
duration_total_hours: IntCriterion | None = None
duration_manual_hours: IntCriterion | None = None
duration_calculated_hours: IntCriterion | None = None
is_active: BoolCriterion | None = None # timestamp_end IS NULL
timestamp_start: StringCriterion | None = None # date string
timestamp_end: StringCriterion | None = None # date string
@@ -454,20 +454,20 @@ class SessionFilter(OperatorFilter):
from datetime import timedelta
q = Q()
td_val = timedelta(minutes=c.value)
td_val = timedelta(hours=c.value)
m = c.modifier
if m == Modifier.EQUALS:
q &= Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
f"{field}__lt": timedelta(hours=c.value + 1),
}
)
elif m == Modifier.NOT_EQUALS:
q &= ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
f"{field}__lt": timedelta(hours=c.value + 1),
}
)
elif m == Modifier.GREATER_THAN:
@@ -475,12 +475,12 @@ class SessionFilter(OperatorFilter):
elif m == Modifier.LESS_THAN:
q &= Q(**{f"{field}__lt": td_val})
elif m == Modifier.BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
lo = timedelta(hours=min(c.value, c.value2))
hi = timedelta(hours=max(c.value, c.value2))
q &= Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
lo = timedelta(hours=min(c.value, c.value2))
hi = timedelta(hours=max(c.value, c.value2))
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
elif m == Modifier.IS_NULL:
q &= Q(**{f"{field}": timedelta(0)})
@@ -501,15 +501,15 @@ class SessionFilter(OperatorFilter):
q &= self.emulated.to_q("emulated")
if self.note is not None:
q &= self.note.to_q("note")
if self.duration_minutes is not None:
q &= self._duration_to_q(self.duration_minutes, "duration_total")
if self.duration_total_minutes is not None:
q &= self._duration_to_q(self.duration_total_minutes, "duration_total")
if self.duration_manual_minutes is not None:
q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual")
if self.duration_calculated_minutes is not None:
if self.duration_hours is not None:
q &= self._duration_to_q(self.duration_hours, "duration_total")
if self.duration_total_hours is not None:
q &= self._duration_to_q(self.duration_total_hours, "duration_total")
if self.duration_manual_hours is not None:
q &= self._duration_to_q(self.duration_manual_hours, "duration_manual")
if self.duration_calculated_hours is not None:
q &= self._duration_to_q(
self.duration_calculated_minutes, "duration_calculated"
self.duration_calculated_hours, "duration_calculated"
)
if self.is_active is not None:
if self.is_active.value:
+3 -15
View File
@@ -1579,6 +1579,9 @@
.w-5 {
width: calc(var(--spacing) * 5);
}
.w-5\/6 {
width: calc(5 / 6 * 100%);
}
.w-10 {
width: calc(var(--spacing) * 10);
}
@@ -3359,11 +3362,6 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.sm\:grid-cols-4 {
@media (width >= 40rem) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.sm\:rounded-t-lg {
@media (width >= 40rem) {
border-top-left-radius: var(--radius-lg);
@@ -3529,21 +3527,11 @@
max-width: var(--breakpoint-2xl);
}
}
.\@sm\:grid-cols-3 {
@container (width >= 24rem) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.\@md\:grid-cols-4 {
@container (width >= 28rem) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.\@lg\:grid-cols-6 {
@container (width >= 32rem) {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}
.rtl\:rotate-180 {
&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
rotate: 180deg;
+6 -6
View File
@@ -152,17 +152,17 @@
{ prefix: "filter-session-average", key: "session_average" },
{ prefix: "filter-purchase-count", key: "purchase_count" },
{ prefix: "filter-playevent-count", key: "playevent_count" },
{ prefix: "filter-duration-total-minutes", key: "duration_total_minutes" },
{ prefix: "filter-duration-manual-minutes", key: "duration_manual_minutes" },
{ prefix: "filter-duration-calculated-minutes", key: "duration_calculated_minutes" },
{ prefix: "filter-manual-playtime-minutes", key: "manual_playtime_minutes" },
{ prefix: "filter-calculated-playtime-minutes", key: "calculated_playtime_minutes" },
{ prefix: "filter-duration-total-hours", key: "duration_total_hours" },
{ prefix: "filter-duration-manual-hours", key: "duration_manual_hours" },
{ prefix: "filter-duration-calculated-hours", key: "duration_calculated_hours" },
{ prefix: "filter-manual-playtime-hours", key: "manual_playtime_hours" },
{ prefix: "filter-calculated-playtime-hours", key: "calculated_playtime_hours" },
{ prefix: "filter-num-purchases", key: "num_purchases" },
{ prefix: "filter-price", key: "price" },
{ prefix: "filter-purchase-price-total", key: "purchase_price_total" },
{ prefix: "filter-purchase-price-any", key: "purchase_price_any" },
{ prefix: "filter-days-to-finish", key: "days_to_finish" },
{ prefix: "filter-playtime", key: "playtime_minutes", convert: function(v) { return Math.round(v * 60); }, ignoreZeroZero: true }
{ prefix: "filter-playtime-hours", key: "playtime_hours", ignoreZeroZero: true }
];
rangeFields.forEach(function (rf) {
+2 -2
View File
@@ -246,8 +246,8 @@ class FilterBarRenderingTest(TestCase):
# New range slider input prefixes
self.assertIn('name="filter-purchase-count-min"', html)
self.assertIn('name="filter-playevent-count-min"', html)
self.assertIn('name="filter-manual-playtime-minutes-min"', html)
self.assertIn('name="filter-calculated-playtime-minutes-min"', html)
self.assertIn('name="filter-manual-playtime-hours-min"', html)
self.assertIn('name="filter-calculated-playtime-hours-min"', html)
self.assertIn('name="filter-original-year-min"', html)
self.assertIn('name="filter-purchase-price-total-min"', html)
self.assertIn('name="filter-purchase-price-any-min"', html)
+12 -12
View File
@@ -706,7 +706,7 @@ class TestExpandedFiltersAgainstDB:
# 2. Device & Session
dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console")
# Session 1: total 40 minutes (30 calc, 10 manual)
# Session 1: total 4 hours (3 hours calc, 1 hour manual)
s1 = Session.objects.create(
game=game,
device=dev,
@@ -714,9 +714,9 @@ class TestExpandedFiltersAgainstDB:
2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
),
timestamp_end=datetime.datetime(
2026, 6, 1, 12, 30, 0, tzinfo=datetime.timezone.utc
2026, 6, 1, 15, 0, 0, tzinfo=datetime.timezone.utc
),
duration_manual=timedelta(minutes=10),
duration_manual=timedelta(hours=1),
)
# 3. Purchase
@@ -786,21 +786,21 @@ class TestExpandedFiltersAgainstDB:
data = self._setup_entities()
# Test duration_total_minutes equals 40
# Test duration_total_hours equals 4
sf_tot = SessionFilter.from_json(
{"duration_total_minutes": {"value": 40, "modifier": "EQUALS"}}
{"duration_total_hours": {"value": 4, "modifier": "EQUALS"}}
)
assert Session.objects.filter(sf_tot.to_q()).count() == 1
# Test duration_manual_minutes equals 10
# Test duration_manual_hours equals 1
sf_man = SessionFilter.from_json(
{"duration_manual_minutes": {"value": 10, "modifier": "EQUALS"}}
{"duration_manual_hours": {"value": 1, "modifier": "EQUALS"}}
)
assert Session.objects.filter(sf_man.to_q()).count() == 1
# Test duration_calculated_minutes equals 30
# Test duration_calculated_hours equals 3
sf_calc = SessionFilter.from_json(
{"duration_calculated_minutes": {"value": 30, "modifier": "EQUALS"}}
{"duration_calculated_hours": {"value": 3, "modifier": "EQUALS"}}
)
assert Session.objects.filter(sf_calc.to_q()).count() == 1
@@ -1017,14 +1017,14 @@ class TestExpandedFiltersAgainstDB:
from games.models import Game
data = self._setup_entities()
# data["s1"] has 10 minutes manual + 30 minutes calculated
# data["s1"] has 1 hour manual + 3 hours calculated
gf_manual = GameFilter.from_json(
{"manual_playtime_minutes": {"value": 10, "modifier": "EQUALS"}}
{"manual_playtime_hours": {"value": 1, "modifier": "EQUALS"}}
)
assert data["game"] in set(Game.objects.filter(gf_manual.to_q()))
gf_calc = GameFilter.from_json(
{"calculated_playtime_minutes": {"value": 30, "modifier": "EQUALS"}}
{"calculated_playtime_hours": {"value": 3, "modifier": "EQUALS"}}
)
assert data["game"] in set(Game.objects.filter(gf_calc.to_q()))