diff --git a/common/components/filters.py b/common/components/filters.py index aa2e5f6..36c6a64 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -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")], diff --git a/games/filters.py b/games/filters.py index f6614c0..29721bb 100644 --- a/games/filters.py +++ b/games/filters.py @@ -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: diff --git a/games/static/base.css b/games/static/base.css index fce8bdc..556712d 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -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; diff --git a/games/static/js/filter_bar.js b/games/static/js/filter_bar.js index a5e7914..9281509 100644 --- a/games/static/js/filter_bar.js +++ b/games/static/js/filter_bar.js @@ -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) { diff --git a/tests/test_filter_bars.py b/tests/test_filter_bars.py index 18a7814..098b178 100644 --- a/tests/test_filter_bars.py +++ b/tests/test_filter_bars.py @@ -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) diff --git a/tests/test_filters.py b/tests/test_filters.py index e6359cb..ec3e49d 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -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()))