Compare commits
2 Commits
9bf7215125
...
7e299d84fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
7e299d84fd
|
|||
|
d021c280d2
|
@@ -272,7 +272,9 @@ def _filter_boolean_radio(name: str, label: str, value: bool | None) -> SafeText
|
|||||||
attributes=[("class", "flex items-center gap-4 h-9")],
|
attributes=[("class", "flex items-center gap-4 h-9")],
|
||||||
children=[
|
children=[
|
||||||
Radio(name=name, label="True", checked=value is True, value="true"),
|
Radio(name=name, label="True", checked=value is True, value="true"),
|
||||||
Radio(name=name, label="False", checked=value is False, value="false"),
|
Radio(
|
||||||
|
name=name, label="False", checked=value is False, value="false"
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -419,7 +421,7 @@ def RangeSlider(
|
|||||||
# ── Slider row ──
|
# ── Slider row ──
|
||||||
Div(
|
Div(
|
||||||
attributes=[
|
attributes=[
|
||||||
("class", "range-slider relative h-10 select-none mt-1"),
|
("class", "range-slider relative h-10 w-5/6 select-none mt-1"),
|
||||||
("data-mode", initial_mode),
|
("data-mode", initial_mode),
|
||||||
("data-min", str(range_min)),
|
("data-min", str(range_min)),
|
||||||
("data-max", str(range_max)),
|
("data-max", str(range_max)),
|
||||||
@@ -748,17 +750,19 @@ def FilterBar(
|
|||||||
purchase_type_choice = _filter_get_choice(existing, "purchase_type")
|
purchase_type_choice = _filter_get_choice(existing, "purchase_type")
|
||||||
purchase_ownership_choice = _filter_get_choice(existing, "purchase_ownership_type")
|
purchase_ownership_choice = _filter_get_choice(existing, "purchase_ownership_type")
|
||||||
playevent_note_value = existing.get("playevent_note", {}).get("value", "")
|
playevent_note_value = existing.get("playevent_note", {}).get("value", "")
|
||||||
playevent_note_modifier = existing.get("playevent_note", {}).get("modifier", "EQUALS")
|
playevent_note_modifier = existing.get("playevent_note", {}).get(
|
||||||
|
"modifier", "EQUALS"
|
||||||
|
)
|
||||||
|
|
||||||
year_min, year_max = _parse_range(existing, "year_released")
|
year_min, year_max = _parse_range(existing, "year_released")
|
||||||
original_year_min, original_year_max = _parse_range(
|
original_year_min, original_year_max = _parse_range(
|
||||||
existing, "original_year_released"
|
existing, "original_year_released"
|
||||||
)
|
)
|
||||||
mastered_value = _parse_bool_nullable(existing, "mastered")
|
mastered_value = _parse_bool_nullable(existing, "mastered")
|
||||||
playtime = existing.get("playtime_minutes", {})
|
playtime = existing.get("playtime_hours", {})
|
||||||
if isinstance(playtime, dict):
|
if isinstance(playtime, dict):
|
||||||
playtime_min = _filter_mins_to_hrs(playtime.get("value", ""))
|
playtime_min = playtime.get("value", "")
|
||||||
playtime_max = _filter_mins_to_hrs(playtime.get("value2", ""))
|
playtime_max = playtime.get("value2", "")
|
||||||
else:
|
else:
|
||||||
playtime_min = ""
|
playtime_min = ""
|
||||||
playtime_max = ""
|
playtime_max = ""
|
||||||
@@ -767,8 +771,8 @@ def FilterBar(
|
|||||||
session_avg_min, session_avg_max = _parse_range(existing, "session_average")
|
session_avg_min, session_avg_max = _parse_range(existing, "session_average")
|
||||||
purchase_count_min, purchase_count_max = _parse_range(existing, "purchase_count")
|
purchase_count_min, purchase_count_max = _parse_range(existing, "purchase_count")
|
||||||
playevent_count_min, playevent_count_max = _parse_range(existing, "playevent_count")
|
playevent_count_min, playevent_count_max = _parse_range(existing, "playevent_count")
|
||||||
manual_pt_min, manual_pt_max = _parse_range(existing, "manual_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_minutes")
|
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_total_min, price_total_max = _parse_range(existing, "purchase_price_total")
|
||||||
price_any_min, price_any_max = _parse_range(existing, "purchase_price_any")
|
price_any_min, price_any_max = _parse_range(existing, "purchase_price_any")
|
||||||
purchase_refunded_value = _parse_bool_nullable(existing, "purchase_refunded")
|
purchase_refunded_value = _parse_bool_nullable(existing, "purchase_refunded")
|
||||||
@@ -912,7 +916,7 @@ def FilterBar(
|
|||||||
"Total playtime",
|
"Total playtime",
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
label="Total playtime",
|
label="Total playtime",
|
||||||
input_name_prefix="filter-playtime",
|
input_name_prefix="filter-playtime-hours",
|
||||||
min_value=playtime_min,
|
min_value=playtime_min,
|
||||||
max_value=playtime_max,
|
max_value=playtime_max,
|
||||||
range_min=0,
|
range_min=0,
|
||||||
@@ -923,45 +927,31 @@ def FilterBar(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Manual Playtime (mins)",
|
"Manual Playtime (hrs)",
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
label="Manual Playtime (mins)",
|
label="Manual Playtime (hrs)",
|
||||||
input_name_prefix="filter-manual-playtime-minutes",
|
input_name_prefix="filter-manual-playtime-hours",
|
||||||
min_value=manual_pt_min,
|
min_value=manual_pt_min,
|
||||||
max_value=manual_pt_max,
|
max_value=manual_pt_max,
|
||||||
range_min=0,
|
range_min=0,
|
||||||
range_max=max(playtime_range_max * 60, 240),
|
range_max=max(playtime_range_max, 4),
|
||||||
step="1",
|
step="1",
|
||||||
min_placeholder="e.g. 10",
|
min_placeholder="e.g. 1",
|
||||||
max_placeholder="e.g. 120",
|
max_placeholder="e.g. 10",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Calculated Playtime (mins)",
|
"Calculated Playtime (hrs)",
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
label="Calculated Playtime (mins)",
|
label="Calculated Playtime (hrs)",
|
||||||
input_name_prefix="filter-calculated-playtime-minutes",
|
input_name_prefix="filter-calculated-playtime-hours",
|
||||||
min_value=calc_pt_min,
|
min_value=calc_pt_min,
|
||||||
max_value=calc_pt_max,
|
max_value=calc_pt_max,
|
||||||
range_min=0,
|
range_min=0,
|
||||||
range_max=max(playtime_range_max * 60, 240),
|
range_max=max(playtime_range_max, 4),
|
||||||
step="1",
|
step="1",
|
||||||
min_placeholder="e.g. 30",
|
min_placeholder="e.g. 1",
|
||||||
max_placeholder="e.g. 120",
|
max_placeholder="e.g. 10",
|
||||||
),
|
|
||||||
),
|
|
||||||
_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",
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
@@ -1086,9 +1076,9 @@ def SessionFilterBar(
|
|||||||
note_value = existing.get("note", {}).get("value", "")
|
note_value = existing.get("note", {}).get("value", "")
|
||||||
note_modifier = existing.get("note", {}).get("modifier", "EQUALS")
|
note_modifier = existing.get("note", {}).get("modifier", "EQUALS")
|
||||||
|
|
||||||
dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_minutes")
|
dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_hours")
|
||||||
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_minutes")
|
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_hours")
|
||||||
dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_minutes")
|
dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_hours")
|
||||||
emulated_value = _parse_bool_nullable(existing, "emulated")
|
emulated_value = _parse_bool_nullable(existing, "emulated")
|
||||||
is_active_value = _parse_bool_nullable(existing, "is_active")
|
is_active_value = _parse_bool_nullable(existing, "is_active")
|
||||||
try:
|
try:
|
||||||
@@ -1138,37 +1128,37 @@ def SessionFilterBar(
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
label="Total Duration (mins)",
|
label="Total Duration (hrs)",
|
||||||
input_name_prefix="filter-duration-total-minutes",
|
input_name_prefix="filter-duration-total-hours",
|
||||||
min_value=dur_tot_min,
|
min_value=dur_tot_min,
|
||||||
max_value=dur_tot_max,
|
max_value=dur_tot_max,
|
||||||
range_min=0,
|
range_min=0,
|
||||||
range_max=duration_range_max * 60, # Range sliders use minutes now
|
range_max=duration_range_max,
|
||||||
step="1",
|
step="1",
|
||||||
min_placeholder="e.g. 30",
|
min_placeholder="e.g. 1",
|
||||||
max_placeholder="e.g. 180",
|
max_placeholder="e.g. 10",
|
||||||
),
|
),
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
label="Manual Duration (mins)",
|
label="Manual Duration (hrs)",
|
||||||
input_name_prefix="filter-duration-manual-minutes",
|
input_name_prefix="filter-duration-manual-hours",
|
||||||
min_value=dur_man_min,
|
min_value=dur_man_min,
|
||||||
max_value=dur_man_max,
|
max_value=dur_man_max,
|
||||||
range_min=0,
|
range_min=0,
|
||||||
range_max=240,
|
range_max=duration_range_max,
|
||||||
step="1",
|
step="1",
|
||||||
min_placeholder="e.g. 10",
|
min_placeholder="e.g. 1",
|
||||||
max_placeholder="e.g. 120",
|
max_placeholder="e.g. 10",
|
||||||
),
|
),
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
label="Calculated Duration (mins)",
|
label="Calculated Duration (hrs)",
|
||||||
input_name_prefix="filter-duration-calculated-minutes",
|
input_name_prefix="filter-duration-calculated-hours",
|
||||||
min_value=dur_calc_min,
|
min_value=dur_calc_min,
|
||||||
max_value=dur_calc_max,
|
max_value=dur_calc_max,
|
||||||
range_min=0,
|
range_min=0,
|
||||||
range_max=duration_range_max * 60,
|
range_max=duration_range_max,
|
||||||
step="1",
|
step="1",
|
||||||
min_placeholder="e.g. 30",
|
min_placeholder="e.g. 1",
|
||||||
max_placeholder="e.g. 180",
|
max_placeholder="e.g. 10",
|
||||||
),
|
),
|
||||||
Div(
|
Div(
|
||||||
attributes=[("class", "flex gap-6 mb-4")],
|
attributes=[("class", "flex gap-6 mb-4")],
|
||||||
@@ -1199,9 +1189,13 @@ def PurchaseFilterBar(
|
|||||||
infinite_value = _parse_bool_nullable(existing, "infinite")
|
infinite_value = _parse_bool_nullable(existing, "infinite")
|
||||||
needs_price_update_value = _parse_bool_nullable(existing, "needs_price_update")
|
needs_price_update_value = _parse_bool_nullable(existing, "needs_price_update")
|
||||||
price_currency_value = existing.get("price_currency", {}).get("value", "")
|
price_currency_value = existing.get("price_currency", {}).get("value", "")
|
||||||
price_currency_modifier = existing.get("price_currency", {}).get("modifier", "EQUALS")
|
price_currency_modifier = existing.get("price_currency", {}).get(
|
||||||
|
"modifier", "EQUALS"
|
||||||
|
)
|
||||||
converted_currency_value = existing.get("converted_currency", {}).get("value", "")
|
converted_currency_value = existing.get("converted_currency", {}).get("value", "")
|
||||||
converted_currency_modifier = existing.get("converted_currency", {}).get("modifier", "EQUALS")
|
converted_currency_modifier = existing.get("converted_currency", {}).get(
|
||||||
|
"modifier", "EQUALS"
|
||||||
|
)
|
||||||
date_purchased_min, date_purchased_max = _parse_range(existing, "date_purchased")
|
date_purchased_min, date_purchased_max = _parse_range(existing, "date_purchased")
|
||||||
date_refunded_min, date_refunded_max = _parse_range(existing, "date_refunded")
|
date_refunded_min, date_refunded_max = _parse_range(existing, "date_refunded")
|
||||||
|
|
||||||
@@ -1344,7 +1338,9 @@ def PurchaseFilterBar(
|
|||||||
_filter_boolean_radio(
|
_filter_boolean_radio(
|
||||||
"filter-refunded", "Refunded", is_refunded_value
|
"filter-refunded", "Refunded", is_refunded_value
|
||||||
),
|
),
|
||||||
_filter_boolean_radio("filter-infinite", "Infinite", infinite_value),
|
_filter_boolean_radio(
|
||||||
|
"filter-infinite", "Infinite", infinite_value
|
||||||
|
),
|
||||||
_filter_boolean_radio(
|
_filter_boolean_radio(
|
||||||
"filter-needs-price-update",
|
"filter-needs-price-update",
|
||||||
"Needs Price Update",
|
"Needs Price Update",
|
||||||
@@ -1495,7 +1491,7 @@ def StringFilter(
|
|||||||
attributes=[
|
attributes=[
|
||||||
("data-string-modifier-radio", ""),
|
("data-string-modifier-radio", ""),
|
||||||
("onclick", "toggleStringFilterInput(this)"),
|
("onclick", "toggleStringFilterInput(this)"),
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
for mod_val, lbl in options
|
for mod_val, lbl in options
|
||||||
]
|
]
|
||||||
@@ -1517,10 +1513,15 @@ def StringFilter(
|
|||||||
input_attrs.append(("disabled", "true"))
|
input_attrs.append(("disabled", "true"))
|
||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
attributes=[("class", "flex flex-col gap-2")],
|
attributes=[("class", "flex flex-col gap-2 @container")],
|
||||||
children=[
|
children=[
|
||||||
Div(
|
Div(
|
||||||
attributes=[("class", "grid grid-cols-2 sm:grid-cols-4 gap-2 py-1")],
|
attributes=[
|
||||||
|
(
|
||||||
|
"class",
|
||||||
|
"grid grid-cols-2 @md:grid-cols-4 gap-2 py-1",
|
||||||
|
)
|
||||||
|
],
|
||||||
children=radio_buttons,
|
children=radio_buttons,
|
||||||
),
|
),
|
||||||
Input(attributes=input_attrs),
|
Input(attributes=input_attrs),
|
||||||
|
|||||||
@@ -459,7 +459,7 @@ def Radio(
|
|||||||
|
|
||||||
return Label(
|
return Label(
|
||||||
attributes=[
|
attributes=[
|
||||||
("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer")
|
("class", "flex items-center gap-1 text-sm text-heading cursor-pointer")
|
||||||
],
|
],
|
||||||
children=[input_el, label],
|
children=[input_el, label],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from common.components import FilterBar
|
||||||
|
|
||||||
|
|
||||||
|
def _bar_page(filter_json: str = "") -> str:
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Range Slider E2E</title>
|
||||||
|
<script src="/static/js/range_slider.js" defer></script>
|
||||||
|
<script src="/static/js/search_select.js" defer></script>
|
||||||
|
<script src="/static/js/filter_bar.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def empty_bar_view(request):
|
||||||
|
return HttpResponse(_bar_page())
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("test-range-slider/", empty_bar_view),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||||
|
def test_range_slider_crossover_min_higher_than_max(live_server, page):
|
||||||
|
page.goto(live_server.url + "/test-range-slider/")
|
||||||
|
|
||||||
|
# 1. Start with known state: Min is empty, Max is empty
|
||||||
|
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||||
|
max_input = page.locator('input[name="filter-session-count-max"]')
|
||||||
|
|
||||||
|
# 2. Type "20" into max input
|
||||||
|
max_input.fill("20")
|
||||||
|
|
||||||
|
# 3. Type "50" into min input (which is higher than 20)
|
||||||
|
min_input.fill("50")
|
||||||
|
|
||||||
|
# 4. Max input should have automatically synchronized/snapped to 50
|
||||||
|
assert max_input.input_value() == "50"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||||
|
def test_range_slider_crossover_max_less_than_min(live_server, page):
|
||||||
|
page.goto(live_server.url + "/test-range-slider/")
|
||||||
|
|
||||||
|
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||||
|
max_input = page.locator('input[name="filter-session-count-max"]')
|
||||||
|
|
||||||
|
# 1. Type "50" into min input
|
||||||
|
min_input.fill("50")
|
||||||
|
|
||||||
|
# 2. Type "30" into max input (which is less than 50)
|
||||||
|
max_input.fill("30")
|
||||||
|
|
||||||
|
# 3. Min input should have automatically synchronized/snapped to 30
|
||||||
|
assert min_input.input_value() == "30"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||||
|
def test_range_slider_strict_bounds_clamping_on_blur(live_server, page):
|
||||||
|
page.goto(live_server.url + "/test-range-slider/")
|
||||||
|
|
||||||
|
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||||
|
max_input = page.locator('input[name="filter-session-count-max"]')
|
||||||
|
|
||||||
|
# 1. Type value higher than dataMax (100 is max, type "150")
|
||||||
|
max_input.fill("150")
|
||||||
|
max_input.blur() # triggers "change" event
|
||||||
|
|
||||||
|
assert max_input.input_value() == "100"
|
||||||
|
|
||||||
|
# 2. Type value lower than dataMin (0 is min, type "-20")
|
||||||
|
min_input.fill("-20")
|
||||||
|
min_input.blur() # triggers "change" event
|
||||||
|
|
||||||
|
assert min_input.input_value() == "0"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||||
|
def test_range_slider_empty_max_thumb_does_not_jump_to_beginning(live_server, page):
|
||||||
|
page.goto(live_server.url + "/test-range-slider/")
|
||||||
|
|
||||||
|
# Locate handles
|
||||||
|
max_handle = page.locator('.range-handle-max[data-target="filter-session-count-max"]')
|
||||||
|
|
||||||
|
# Initially, max_input is empty, so handle should sit at 100% (far right)
|
||||||
|
style = max_handle.get_attribute("style")
|
||||||
|
assert "left:100%" in style or "left: 100%" in style
|
||||||
|
|
||||||
|
# Set min to 50
|
||||||
|
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||||
|
min_input.fill("50")
|
||||||
|
|
||||||
|
# Max handle should STILL stay at 100% since max input is still empty (defaults to max_value)
|
||||||
|
style = max_handle.get_attribute("style")
|
||||||
|
assert "left:100%" in style or "left: 100%" in style
|
||||||
+39
-39
@@ -62,18 +62,18 @@ class GameFilter(OperatorFilter):
|
|||||||
platform_group: MultiCriterion | None = None # platform__group__in
|
platform_group: MultiCriterion | None = None # platform__group__in
|
||||||
status: ChoiceCriterion | None = None # selectable filter widget
|
status: ChoiceCriterion | None = None # selectable filter widget
|
||||||
mastered: BoolCriterion | None = None
|
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
|
created_at: StringCriterion | None = None # date string
|
||||||
updated_at: StringCriterion | None = None # date string
|
updated_at: StringCriterion | None = None # date string
|
||||||
|
|
||||||
session_count: IntCriterion | None = None
|
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
|
purchase_count: IntCriterion | None = None # distinct purchases per game
|
||||||
playevent_count: IntCriterion | None = None # playevents per game
|
playevent_count: IntCriterion | None = None # playevents per game
|
||||||
|
|
||||||
# Aggregate session durations (minutes), summed across the game's sessions
|
# Aggregate session durations (hours), summed across the game's sessions
|
||||||
manual_playtime_minutes: IntCriterion | None = None
|
manual_playtime_hours: IntCriterion | None = None
|
||||||
calculated_playtime_minutes: IntCriterion | None = None
|
calculated_playtime_hours: IntCriterion | None = None
|
||||||
|
|
||||||
# Cross-entity: any session played on these devices / matching these flags
|
# Cross-entity: any session played on these devices / matching these flags
|
||||||
device: MultiCriterion | None = None # game has session on any of these devices
|
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")
|
q &= self.status.to_q("status")
|
||||||
if self.mastered is not None:
|
if self.mastered is not None:
|
||||||
q &= self.mastered.to_q("mastered")
|
q &= self.mastered.to_q("mastered")
|
||||||
if self.playtime_minutes is not None:
|
if self.playtime_hours is not None:
|
||||||
q &= self._playtime_to_q(self.playtime_minutes)
|
q &= self._playtime_to_q(self.playtime_hours)
|
||||||
if self.created_at is not None:
|
if self.created_at is not None:
|
||||||
q &= self.created_at.to_q("created_at")
|
q &= self.created_at.to_q("created_at")
|
||||||
if self.updated_at is not None:
|
if self.updated_at is not None:
|
||||||
@@ -177,7 +177,7 @@ class GameFilter(OperatorFilter):
|
|||||||
)
|
)
|
||||||
q &= Q(id__in=matching_ids)
|
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 django.db.models import Sum
|
||||||
|
|
||||||
from games.models import Game
|
from games.models import Game
|
||||||
@@ -186,14 +186,14 @@ class GameFilter(OperatorFilter):
|
|||||||
Game.objects.annotate(s_manual=Sum("sessions__duration_manual"))
|
Game.objects.annotate(s_manual=Sum("sessions__duration_manual"))
|
||||||
.filter(
|
.filter(
|
||||||
self._playtime_to_q_for_field(
|
self._playtime_to_q_for_field(
|
||||||
self.manual_playtime_minutes, "s_manual"
|
self.manual_playtime_hours, "s_manual"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.values_list("id", flat=True)
|
.values_list("id", flat=True)
|
||||||
)
|
)
|
||||||
q &= Q(id__in=matching_ids)
|
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 django.db.models import Sum
|
||||||
|
|
||||||
from games.models import Game
|
from games.models import Game
|
||||||
@@ -202,7 +202,7 @@ class GameFilter(OperatorFilter):
|
|||||||
Game.objects.annotate(s_calc=Sum("sessions__duration_calculated"))
|
Game.objects.annotate(s_calc=Sum("sessions__duration_calculated"))
|
||||||
.filter(
|
.filter(
|
||||||
self._playtime_to_q_for_field(
|
self._playtime_to_q_for_field(
|
||||||
self.calculated_playtime_minutes, "s_calc"
|
self.calculated_playtime_hours, "s_calc"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.values_list("id", flat=True)
|
.values_list("id", flat=True)
|
||||||
@@ -362,30 +362,30 @@ class GameFilter(OperatorFilter):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
|
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
|
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 datetime import timedelta
|
||||||
|
|
||||||
from common.criteria import Modifier
|
from common.criteria import Modifier
|
||||||
|
|
||||||
m = c.modifier
|
m = c.modifier
|
||||||
td_val = timedelta(minutes=c.value)
|
td_val = timedelta(hours=c.value)
|
||||||
|
|
||||||
if m == Modifier.EQUALS:
|
if m == Modifier.EQUALS:
|
||||||
return Q(
|
return Q(
|
||||||
**{
|
**{
|
||||||
f"{field}__gte": td_val,
|
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:
|
if m == Modifier.NOT_EQUALS:
|
||||||
return ~Q(
|
return ~Q(
|
||||||
**{
|
**{
|
||||||
f"{field}__gte": td_val,
|
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:
|
if m == Modifier.GREATER_THAN:
|
||||||
@@ -393,12 +393,12 @@ class GameFilter(OperatorFilter):
|
|||||||
if m == Modifier.LESS_THAN:
|
if m == Modifier.LESS_THAN:
|
||||||
return Q(**{f"{field}__lt": td_val})
|
return Q(**{f"{field}__lt": td_val})
|
||||||
if m == Modifier.BETWEEN and c.value2 is not None:
|
if m == Modifier.BETWEEN and c.value2 is not None:
|
||||||
lo = timedelta(minutes=min(c.value, c.value2))
|
lo = timedelta(hours=min(c.value, c.value2))
|
||||||
hi = timedelta(minutes=max(c.value, c.value2))
|
hi = timedelta(hours=max(c.value, c.value2))
|
||||||
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
||||||
if m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
if m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||||
lo = timedelta(minutes=min(c.value, c.value2))
|
lo = timedelta(hours=min(c.value, c.value2))
|
||||||
hi = timedelta(minutes=max(c.value, c.value2))
|
hi = timedelta(hours=max(c.value, c.value2))
|
||||||
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||||
if m == Modifier.IS_NULL:
|
if m == Modifier.IS_NULL:
|
||||||
return Q(**{f"{field}": timedelta(0)})
|
return Q(**{f"{field}": timedelta(0)})
|
||||||
@@ -431,10 +431,10 @@ class SessionFilter(OperatorFilter):
|
|||||||
device: MultiCriterion | None = None # filters on device_id
|
device: MultiCriterion | None = None # filters on device_id
|
||||||
emulated: BoolCriterion | None = None
|
emulated: BoolCriterion | None = None
|
||||||
note: StringCriterion | None = None
|
note: StringCriterion | None = None
|
||||||
duration_minutes: IntCriterion | None = None # on duration_total (legacy alias)
|
duration_hours: IntCriterion | None = None # on duration_total (legacy alias)
|
||||||
duration_total_minutes: IntCriterion | None = None
|
duration_total_hours: IntCriterion | None = None
|
||||||
duration_manual_minutes: IntCriterion | None = None
|
duration_manual_hours: IntCriterion | None = None
|
||||||
duration_calculated_minutes: IntCriterion | None = None
|
duration_calculated_hours: IntCriterion | None = None
|
||||||
is_active: BoolCriterion | None = None # timestamp_end IS NULL
|
is_active: BoolCriterion | None = None # timestamp_end IS NULL
|
||||||
timestamp_start: StringCriterion | None = None # date string
|
timestamp_start: StringCriterion | None = None # date string
|
||||||
timestamp_end: StringCriterion | None = None # date string
|
timestamp_end: StringCriterion | None = None # date string
|
||||||
@@ -454,20 +454,20 @@ class SessionFilter(OperatorFilter):
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
q = Q()
|
q = Q()
|
||||||
td_val = timedelta(minutes=c.value)
|
td_val = timedelta(hours=c.value)
|
||||||
m = c.modifier
|
m = c.modifier
|
||||||
if m == Modifier.EQUALS:
|
if m == Modifier.EQUALS:
|
||||||
q &= Q(
|
q &= Q(
|
||||||
**{
|
**{
|
||||||
f"{field}__gte": td_val,
|
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:
|
elif m == Modifier.NOT_EQUALS:
|
||||||
q &= ~Q(
|
q &= ~Q(
|
||||||
**{
|
**{
|
||||||
f"{field}__gte": td_val,
|
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:
|
elif m == Modifier.GREATER_THAN:
|
||||||
@@ -475,12 +475,12 @@ class SessionFilter(OperatorFilter):
|
|||||||
elif m == Modifier.LESS_THAN:
|
elif m == Modifier.LESS_THAN:
|
||||||
q &= Q(**{f"{field}__lt": td_val})
|
q &= Q(**{f"{field}__lt": td_val})
|
||||||
elif m == Modifier.BETWEEN and c.value2 is not None:
|
elif m == Modifier.BETWEEN and c.value2 is not None:
|
||||||
lo = timedelta(minutes=min(c.value, c.value2))
|
lo = timedelta(hours=min(c.value, c.value2))
|
||||||
hi = timedelta(minutes=max(c.value, c.value2))
|
hi = timedelta(hours=max(c.value, c.value2))
|
||||||
q &= Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
q &= Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
||||||
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||||
lo = timedelta(minutes=min(c.value, c.value2))
|
lo = timedelta(hours=min(c.value, c.value2))
|
||||||
hi = timedelta(minutes=max(c.value, c.value2))
|
hi = timedelta(hours=max(c.value, c.value2))
|
||||||
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||||
elif m == Modifier.IS_NULL:
|
elif m == Modifier.IS_NULL:
|
||||||
q &= Q(**{f"{field}": timedelta(0)})
|
q &= Q(**{f"{field}": timedelta(0)})
|
||||||
@@ -501,15 +501,15 @@ class SessionFilter(OperatorFilter):
|
|||||||
q &= self.emulated.to_q("emulated")
|
q &= self.emulated.to_q("emulated")
|
||||||
if self.note is not None:
|
if self.note is not None:
|
||||||
q &= self.note.to_q("note")
|
q &= self.note.to_q("note")
|
||||||
if self.duration_minutes is not None:
|
if self.duration_hours is not None:
|
||||||
q &= self._duration_to_q(self.duration_minutes, "duration_total")
|
q &= self._duration_to_q(self.duration_hours, "duration_total")
|
||||||
if self.duration_total_minutes is not None:
|
if self.duration_total_hours is not None:
|
||||||
q &= self._duration_to_q(self.duration_total_minutes, "duration_total")
|
q &= self._duration_to_q(self.duration_total_hours, "duration_total")
|
||||||
if self.duration_manual_minutes is not None:
|
if self.duration_manual_hours is not None:
|
||||||
q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual")
|
q &= self._duration_to_q(self.duration_manual_hours, "duration_manual")
|
||||||
if self.duration_calculated_minutes is not None:
|
if self.duration_calculated_hours is not None:
|
||||||
q &= self._duration_to_q(
|
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 is not None:
|
||||||
if self.is_active.value:
|
if self.is_active.value:
|
||||||
|
|||||||
@@ -466,6 +466,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
.\@container {
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
.pointer-events-auto {
|
.pointer-events-auto {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
@@ -1576,6 +1579,9 @@
|
|||||||
.w-5 {
|
.w-5 {
|
||||||
width: calc(var(--spacing) * 5);
|
width: calc(var(--spacing) * 5);
|
||||||
}
|
}
|
||||||
|
.w-5\/6 {
|
||||||
|
width: calc(5 / 6 * 100%);
|
||||||
|
}
|
||||||
.w-10 {
|
.w-10 {
|
||||||
width: calc(var(--spacing) * 10);
|
width: calc(var(--spacing) * 10);
|
||||||
}
|
}
|
||||||
@@ -1742,6 +1748,9 @@
|
|||||||
.grid-cols-1 {
|
.grid-cols-1 {
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
.grid-cols-2 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
.grid-cols-4 {
|
.grid-cols-4 {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -2708,6 +2717,9 @@
|
|||||||
.opacity-0 {
|
.opacity-0 {
|
||||||
opacity: 0%;
|
opacity: 0%;
|
||||||
}
|
}
|
||||||
|
.opacity-50 {
|
||||||
|
opacity: 50%;
|
||||||
|
}
|
||||||
.opacity-100 {
|
.opacity-100 {
|
||||||
opacity: 100%;
|
opacity: 100%;
|
||||||
}
|
}
|
||||||
@@ -2761,6 +2773,11 @@
|
|||||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||||
}
|
}
|
||||||
|
.transition-all {
|
||||||
|
transition-property: all;
|
||||||
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||||
|
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||||
|
}
|
||||||
.transition-opacity {
|
.transition-opacity {
|
||||||
transition-property: opacity;
|
transition-property: opacity;
|
||||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||||
@@ -3510,6 +3527,11 @@
|
|||||||
max-width: var(--breakpoint-2xl);
|
max-width: var(--breakpoint-2xl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.\@md\:grid-cols-4 {
|
||||||
|
@container (width >= 28rem) {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
.rtl\:rotate-180 {
|
.rtl\:rotate-180 {
|
||||||
&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
|
&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
|
||||||
rotate: 180deg;
|
rotate: 180deg;
|
||||||
|
|||||||
@@ -152,17 +152,17 @@
|
|||||||
{ prefix: "filter-session-average", key: "session_average" },
|
{ prefix: "filter-session-average", key: "session_average" },
|
||||||
{ prefix: "filter-purchase-count", key: "purchase_count" },
|
{ prefix: "filter-purchase-count", key: "purchase_count" },
|
||||||
{ prefix: "filter-playevent-count", key: "playevent_count" },
|
{ prefix: "filter-playevent-count", key: "playevent_count" },
|
||||||
{ prefix: "filter-duration-total-minutes", key: "duration_total_minutes" },
|
{ prefix: "filter-duration-total-hours", key: "duration_total_hours" },
|
||||||
{ prefix: "filter-duration-manual-minutes", key: "duration_manual_minutes" },
|
{ prefix: "filter-duration-manual-hours", key: "duration_manual_hours" },
|
||||||
{ prefix: "filter-duration-calculated-minutes", key: "duration_calculated_minutes" },
|
{ prefix: "filter-duration-calculated-hours", key: "duration_calculated_hours" },
|
||||||
{ prefix: "filter-manual-playtime-minutes", key: "manual_playtime_minutes" },
|
{ prefix: "filter-manual-playtime-hours", key: "manual_playtime_hours" },
|
||||||
{ prefix: "filter-calculated-playtime-minutes", key: "calculated_playtime_minutes" },
|
{ prefix: "filter-calculated-playtime-hours", key: "calculated_playtime_hours" },
|
||||||
{ prefix: "filter-num-purchases", key: "num_purchases" },
|
{ prefix: "filter-num-purchases", key: "num_purchases" },
|
||||||
{ prefix: "filter-price", key: "price" },
|
{ prefix: "filter-price", key: "price" },
|
||||||
{ prefix: "filter-purchase-price-total", key: "purchase_price_total" },
|
{ prefix: "filter-purchase-price-total", key: "purchase_price_total" },
|
||||||
{ prefix: "filter-purchase-price-any", key: "purchase_price_any" },
|
{ prefix: "filter-purchase-price-any", key: "purchase_price_any" },
|
||||||
{ prefix: "filter-days-to-finish", key: "days_to_finish" },
|
{ 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) {
|
rangeFields.forEach(function (rf) {
|
||||||
|
|||||||
@@ -46,8 +46,10 @@
|
|||||||
return Math.max(lo, Math.min(hi, value));
|
return Math.max(lo, Math.min(hi, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTargetValue(target) {
|
function getTargetValue(target, defaultVal) {
|
||||||
return parseInt(target ? target.value : 0, 10) || dataMin;
|
if (!target || target.value === "") return defaultVal;
|
||||||
|
var parsed = parseInt(target.value, 10);
|
||||||
|
return isNaN(parsed) ? defaultVal : parsed;
|
||||||
}
|
}
|
||||||
function setTargetValue(target, value) {
|
function setTargetValue(target, value) {
|
||||||
if (target) target.value = value;
|
if (target) target.value = value;
|
||||||
@@ -57,22 +59,30 @@
|
|||||||
|
|
||||||
function updateTrackFill() {
|
function updateTrackFill() {
|
||||||
if (!trackFill) return;
|
if (!trackFill) return;
|
||||||
var minValue = getTargetValue(minTarget);
|
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
|
||||||
var maxValue = getTargetValue(maxTarget);
|
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
|
||||||
if (mode === "point") {
|
if (mode === "point") {
|
||||||
trackFill.style.left = "0%";
|
trackFill.style.left = "0%";
|
||||||
trackFill.style.width = valueToPercent(maxValue) + "%";
|
trackFill.style.width = valueToPercent(maxVal) + "%";
|
||||||
} else {
|
} else {
|
||||||
var leftPct = valueToPercent(minValue);
|
var leftPct = valueToPercent(minVal);
|
||||||
var widthPct = valueToPercent(maxValue) - leftPct;
|
var rightPct = valueToPercent(maxVal);
|
||||||
|
if (leftPct > rightPct) {
|
||||||
|
var tmp = leftPct;
|
||||||
|
leftPct = rightPct;
|
||||||
|
rightPct = tmp;
|
||||||
|
}
|
||||||
|
var widthPct = rightPct - leftPct;
|
||||||
trackFill.style.left = leftPct + "%";
|
trackFill.style.left = leftPct + "%";
|
||||||
trackFill.style.width = widthPct + "%";
|
trackFill.style.width = widthPct + "%";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHandles() {
|
function updateHandles() {
|
||||||
minHandle.style.left = valueToPercent(getTargetValue(minTarget)) + "%";
|
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
|
||||||
maxHandle.style.left = valueToPercent(getTargetValue(maxTarget)) + "%";
|
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
|
||||||
|
minHandle.style.left = valueToPercent(minVal) + "%";
|
||||||
|
maxHandle.style.left = valueToPercent(maxVal) + "%";
|
||||||
updateTrackFill();
|
updateTrackFill();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +111,7 @@
|
|||||||
} else if (isMin) {
|
} else if (isMin) {
|
||||||
setTargetValue(
|
setTargetValue(
|
||||||
minTarget,
|
minTarget,
|
||||||
clamp(value, dataMin, getTargetValue(maxTarget))
|
clamp(value, dataMin, getTargetValue(maxTarget, dataMax))
|
||||||
);
|
);
|
||||||
if (minTarget)
|
if (minTarget)
|
||||||
minTarget.dispatchEvent(
|
minTarget.dispatchEvent(
|
||||||
@@ -110,7 +120,7 @@
|
|||||||
} else {
|
} else {
|
||||||
setTargetValue(
|
setTargetValue(
|
||||||
maxTarget,
|
maxTarget,
|
||||||
clamp(value, getTargetValue(minTarget), dataMax)
|
clamp(value, getTargetValue(minTarget, dataMin), dataMax)
|
||||||
);
|
);
|
||||||
if (maxTarget)
|
if (maxTarget)
|
||||||
maxTarget.dispatchEvent(
|
maxTarget.dispatchEvent(
|
||||||
@@ -135,19 +145,49 @@
|
|||||||
|
|
||||||
// ── Sync from number inputs back to handles ──
|
// ── Sync from number inputs back to handles ──
|
||||||
|
|
||||||
function syncFromInputs() {
|
function syncFromInputs(e) {
|
||||||
if (mode === "point") {
|
if (mode === "point") {
|
||||||
var value =
|
var src = (e && e.target) || minTarget || maxTarget;
|
||||||
getTargetValue(minTarget) || getTargetValue(maxTarget);
|
var val = src ? src.value : "";
|
||||||
setTargetValue(minTarget, value);
|
setTargetValue(minTarget, val);
|
||||||
setTargetValue(maxTarget, value);
|
setTargetValue(maxTarget, val);
|
||||||
|
} else if (e && e.target) {
|
||||||
|
var minVal = getTargetValue(minTarget, dataMin);
|
||||||
|
var maxVal = getTargetValue(maxTarget, dataMax);
|
||||||
|
if (e.target === minTarget) {
|
||||||
|
if (minVal > maxVal) {
|
||||||
|
setTargetValue(maxTarget, minVal);
|
||||||
|
}
|
||||||
|
} else if (e.target === maxTarget) {
|
||||||
|
if (maxVal < minVal) {
|
||||||
|
setTargetValue(minTarget, maxVal);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
updateHandles();
|
updateHandles();
|
||||||
}
|
}
|
||||||
if (minTarget)
|
|
||||||
|
function enforceStrictBounds(e) {
|
||||||
|
if (e && e.target) {
|
||||||
|
var val = parseInt(e.target.value, 10);
|
||||||
|
if (!isNaN(val)) {
|
||||||
|
var clamped = clamp(val, dataMin, dataMax);
|
||||||
|
if (clamped !== val) {
|
||||||
|
setTargetValue(e.target, clamped);
|
||||||
|
e.target.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minTarget) {
|
||||||
minTarget.addEventListener("input", syncFromInputs);
|
minTarget.addEventListener("input", syncFromInputs);
|
||||||
if (maxTarget)
|
minTarget.addEventListener("change", enforceStrictBounds);
|
||||||
|
}
|
||||||
|
if (maxTarget) {
|
||||||
maxTarget.addEventListener("input", syncFromInputs);
|
maxTarget.addEventListener("input", syncFromInputs);
|
||||||
|
maxTarget.addEventListener("change", enforceStrictBounds);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Mode toggle ──
|
// ── Mode toggle ──
|
||||||
|
|
||||||
@@ -172,7 +212,7 @@
|
|||||||
var dashSpan = block && block.querySelector(".range-dash");
|
var dashSpan = block && block.querySelector(".range-dash");
|
||||||
if (newMode === "point") {
|
if (newMode === "point") {
|
||||||
minHandle.style.display = "none";
|
minHandle.style.display = "none";
|
||||||
setTargetValue(minTarget, getTargetValue(maxTarget));
|
setTargetValue(minTarget, maxTarget ? maxTarget.value : "");
|
||||||
if (minTarget) minTarget.classList.add("hidden");
|
if (minTarget) minTarget.classList.add("hidden");
|
||||||
if (dashSpan) dashSpan.classList.add("hidden");
|
if (dashSpan) dashSpan.classList.add("hidden");
|
||||||
} else {
|
} else {
|
||||||
@@ -193,4 +233,4 @@
|
|||||||
document.addEventListener("DOMContentLoaded", initAll);
|
document.addEventListener("DOMContentLoaded", initAll);
|
||||||
document.addEventListener("htmx:afterSwap", initAll);
|
document.addEventListener("htmx:afterSwap", initAll);
|
||||||
window.initRangeSliders = initAll;
|
window.initRangeSliders = initAll;
|
||||||
})();
|
})();
|
||||||
@@ -246,8 +246,8 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
# New range slider input prefixes
|
# New range slider input prefixes
|
||||||
self.assertIn('name="filter-purchase-count-min"', html)
|
self.assertIn('name="filter-purchase-count-min"', html)
|
||||||
self.assertIn('name="filter-playevent-count-min"', html)
|
self.assertIn('name="filter-playevent-count-min"', html)
|
||||||
self.assertIn('name="filter-manual-playtime-minutes-min"', html)
|
self.assertIn('name="filter-manual-playtime-hours-min"', html)
|
||||||
self.assertIn('name="filter-calculated-playtime-minutes-min"', html)
|
self.assertIn('name="filter-calculated-playtime-hours-min"', html)
|
||||||
self.assertIn('name="filter-original-year-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-total-min"', html)
|
||||||
self.assertIn('name="filter-purchase-price-any-min"', html)
|
self.assertIn('name="filter-purchase-price-any-min"', html)
|
||||||
|
|||||||
+12
-12
@@ -706,7 +706,7 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
# 2. Device & Session
|
# 2. Device & Session
|
||||||
dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console")
|
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(
|
s1 = Session.objects.create(
|
||||||
game=game,
|
game=game,
|
||||||
device=dev,
|
device=dev,
|
||||||
@@ -714,9 +714,9 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
|
2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
|
||||||
),
|
),
|
||||||
timestamp_end=datetime.datetime(
|
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
|
# 3. Purchase
|
||||||
@@ -786,21 +786,21 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
|
|
||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
|
|
||||||
# Test duration_total_minutes equals 40
|
# Test duration_total_hours equals 4
|
||||||
sf_tot = SessionFilter.from_json(
|
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
|
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(
|
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
|
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(
|
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
|
assert Session.objects.filter(sf_calc.to_q()).count() == 1
|
||||||
|
|
||||||
@@ -1017,14 +1017,14 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
from games.models import Game
|
from games.models import Game
|
||||||
|
|
||||||
data = self._setup_entities()
|
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(
|
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()))
|
assert data["game"] in set(Game.objects.filter(gf_manual.to_q()))
|
||||||
|
|
||||||
gf_calc = GameFilter.from_json(
|
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()))
|
assert data["game"] in set(Game.objects.filter(gf_calc.to_q()))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user