Skip to content

Commit b8b986f

Browse files
committed
feat: add custom date-range leaderboard across Jinja and React
Introduce custom start/end leaderboard ranges in both server-rendered and SPA flows while fixing monthly end-date consistency. - Add /report/range (Jinja) and /json/report/range endpoints with YYYY-MM-DD validation and max-range guard - Add shared date-range parser utility with tests - Wire React Rankings to /json/report/range via /rankings/range UI and API client updates - Add explore-page entry for custom ranges - Fix monthly report end-date calculation using last_day_of_calendar_month for HTML and JSON parity
1 parent 5a7b0b9 commit b8b986f

File tree

10 files changed

+372
-49
lines changed

10 files changed

+372
-49
lines changed

frontend/src/features/call.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,14 @@ export const callUnit = createApi({
6363
providesTags: (result, error, category) => [{ type: "Category", id: category }],
6464
}),
6565
retrieveRankings: builder.query({
66-
query: ({ y, w, m, d, begin = 0, limit = 200 } = {}) => {
66+
query: ({ y, w, m, d, start, end, begin = 0, limit = 200 } = {}) => {
67+
if (start && end) {
68+
return {
69+
url: "report/range",
70+
method: "GET",
71+
params: { start, end, begin, limit },
72+
};
73+
}
6774
let link = "report";
6875
if (y) {
6976
link += `/y/${y}`;

frontend/src/main.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ createRoot(document.getElementById("root")).render(
4242
<Route element={<Recently />} path="recently" />
4343
<Route path="rankings">
4444
<Route element={<Rankings />} index />
45+
<Route element={<Rankings />} path="range" />
4546
<Route path="y/:y">
4647
<Route element={<Rankings />} index />
4748
<Route path="m/:m">

frontend/src/routes/rankings.jsx

Lines changed: 112 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { mdiCalendarCheck, mdiCalendarHeart, mdiCalendarMonth, mdiCalendarRange, mdiCalendarWeek } from "@mdi/js";
22
import Icon from "@mdi/react";
3-
import { useEffect } from "react";
3+
import { useEffect, useState } from "react";
44
import { Badge, Button, Card, Form, ListGroup } from "react-bootstrap";
55
import { useDispatch, useSelector } from "react-redux";
6-
import { Link, useLocation, useNavigate, useParams } from "react-router";
6+
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router";
77

88
import VertItem from "../components/vertitem.jsx";
99
import { useRetrieveRankingsQuery } from "../features/call.js";
@@ -18,9 +18,20 @@ export default function Rankings() {
1818
const thisdate = new Date();
1919
const { y, m, d } = useParams();
2020
const isweekly = location.pathname.endsWith("/week");
21+
const isCustomRange = location.pathname === "/rankings/range";
22+
const [searchParams, setSearchParams] = useSearchParams();
23+
const rangeStart = searchParams.get("start");
24+
const rangeEnd = searchParams.get("end");
25+
const [startField, setStartField] = useState(rangeStart || "");
26+
const [endField, setEndField] = useState(rangeEnd || "");
2127
const pickdate = useSelector((state) => state.area.date);
2228
const vibe = useSelector((data) => data.area.vibe);
2329

30+
useEffect(() => {
31+
setStartField(rangeStart || "");
32+
setEndField(rangeEnd || "");
33+
}, [rangeStart, rangeEnd]);
34+
2435
const readDate = () => {
2536
if (y && m && d) {
2637
return `${y}-${m.toString().padStart(2, "0")}-${d.toString().padStart(2, "0")}`;
@@ -41,14 +52,21 @@ export default function Rankings() {
4152
}
4253
};
4354

44-
const params = {
55+
const stdParams = {
4556
...(y && { y: parseInt(y) }),
4657
...(m && { m: parseInt(m) }),
4758
...(d && { d: parseInt(d) }),
4859
...(isweekly && { w: true }),
4960
};
5061

51-
const { data: dict, isLoading, error } = useRetrieveRankingsQuery(params, { skip: false });
62+
const apiParams =
63+
isCustomRange && rangeStart && rangeEnd
64+
? { start: rangeStart, end: rangeEnd }
65+
: stdParams;
66+
67+
const skipRetrieve = isCustomRange && (!rangeStart || !rangeEnd);
68+
69+
const { data: dict, isLoading, error } = useRetrieveRankingsQuery(apiParams, { skip: skipRetrieve });
5270

5371
// Show or Hide LoadNote
5472
useEffect(() => {
@@ -63,32 +81,41 @@ export default function Rankings() {
6381
return <Mistaken />;
6482
}
6583

66-
if (isLoading || !dict) {
84+
const loadingBlocked =
85+
(!isCustomRange && (isLoading || !dict)) ||
86+
(isCustomRange && rangeStart && rangeEnd && (isLoading || !dict));
87+
88+
if (loadingBlocked) {
6789
return null;
6890
}
6991

7092
const showDate = () => {
71-
if (Object.keys(params).length === 0) return "All time";
93+
if (isCustomRange) {
94+
if (rangeStart && rangeEnd) return `${rangeStart} to ${rangeEnd}`;
95+
return "Pick start and end dates";
96+
}
97+
if (Object.keys(stdParams).length === 0) return "All time";
7298

7399
const date = new Date();
74-
if (params.y) date.setFullYear(params.y);
75-
if (params.m) date.setMonth(params.m - 1);
76-
if (params.d) date.setDate(params.d);
100+
if (stdParams.y) date.setFullYear(stdParams.y);
101+
if (stdParams.m) date.setMonth(stdParams.m - 1);
102+
if (stdParams.d) date.setDate(stdParams.d);
77103

78104
const option = {};
79-
if (params.y) option.year = "numeric";
80-
if (params.m) option.month = "long";
81-
if (params.d) option.day = "numeric";
105+
if (stdParams.y) option.year = "numeric";
106+
if (stdParams.m) option.month = "long";
107+
if (stdParams.d) option.day = "numeric";
82108

83109
return date.toLocaleDateString("en-US", option);
84110
};
85111

86112
const showHead = () => {
113+
if (isCustomRange) return "Custom range";
87114
let name = "";
88-
if (params.w) name = "Weekly";
89-
else if (params.d) name = "Daily";
90-
else if (params.m) name = "Monthly";
91-
else if (params.y) name = "Yearly";
115+
if (stdParams.w) name = "Weekly";
116+
else if (stdParams.d) name = "Daily";
117+
else if (stdParams.m) name = "Monthly";
118+
else if (stdParams.y) name = "Yearly";
92119
else name = "All time";
93120
return name;
94121
};
@@ -108,27 +135,27 @@ export default function Rankings() {
108135
const scanDate = (conf = "") => {
109136
if (conf === "d")
110137
return (
111-
params.y &&
112-
params.m &&
113-
params.d &&
114-
(params.y !== thisdate.getFullYear() ||
115-
params.m !== thisdate.getMonth() + 1 ||
116-
params.d !== thisdate.getDate()) &&
138+
stdParams.y &&
139+
stdParams.m &&
140+
stdParams.d &&
141+
(stdParams.y !== thisdate.getFullYear() ||
142+
stdParams.m !== thisdate.getMonth() + 1 ||
143+
stdParams.d !== thisdate.getDate()) &&
117144
isweekly
118145
);
119146
else if (conf === "w")
120147
return (
121-
params.y &&
122-
params.m &&
123-
params.d &&
124-
(params.y !== thisdate.getFullYear() ||
125-
params.m !== thisdate.getMonth() + 1 ||
126-
params.d !== thisdate.getDate()) &&
148+
stdParams.y &&
149+
stdParams.m &&
150+
stdParams.d &&
151+
(stdParams.y !== thisdate.getFullYear() ||
152+
stdParams.m !== thisdate.getMonth() + 1 ||
153+
stdParams.d !== thisdate.getDate()) &&
127154
!isweekly
128155
);
129156
else if (conf === "m")
130-
return params.y && params.m && (params.y !== thisdate.getFullYear() || params.m !== thisdate.getMonth() + 1);
131-
else if (conf === "y") return params.y && params.y !== thisdate.getFullYear();
157+
return stdParams.y && stdParams.m && (stdParams.y !== thisdate.getFullYear() || stdParams.m !== thisdate.getMonth() + 1);
158+
else if (conf === "y") return stdParams.y && stdParams.y !== thisdate.getFullYear();
132159
return false;
133160
};
134161

@@ -139,9 +166,43 @@ export default function Rankings() {
139166
<Card.Body className="p-2">
140167
<Card.Title className="dataelem text-truncate">Rankings</Card.Title>
141168
<Card.Text className="small">{showDate()}</Card.Text>
142-
<Card.Text>
143-
<Form.Control type="date" value={readDate()} onChange={handleChange} size="sm" autoComplete="off" />
144-
</Card.Text>
169+
{isCustomRange ? (
170+
<Card.Text>
171+
<Form
172+
onSubmit={(e) => {
173+
e.preventDefault();
174+
if (!startField || !endField) return;
175+
setSearchParams({ start: startField, end: endField });
176+
}}
177+
>
178+
<Form.Label className="small mb-0">Start</Form.Label>
179+
<Form.Control
180+
type="date"
181+
value={startField}
182+
onChange={(e) => setStartField(e.target.value)}
183+
className="mb-2"
184+
size="sm"
185+
autoComplete="off"
186+
/>
187+
<Form.Label className="small mb-0">End</Form.Label>
188+
<Form.Control
189+
type="date"
190+
value={endField}
191+
onChange={(e) => setEndField(e.target.value)}
192+
className="mb-2"
193+
size="sm"
194+
autoComplete="off"
195+
/>
196+
<Button type="submit" size="sm" variant="outline-secondary" className="w-100 vibe-border" style={{ "--vibe": vibe }}>
197+
Show rankings
198+
</Button>
199+
</Form>
200+
</Card.Text>
201+
) : (
202+
<Card.Text>
203+
<Form.Control type="date" value={readDate()} onChange={handleChange} size="sm" autoComplete="off" />
204+
</Card.Text>
205+
)}
145206
</Card.Body>
146207
</Card>
147208
<div className="d-grid gap-2">
@@ -200,11 +261,22 @@ export default function Rankings() {
200261
<Icon path={mdiCalendarHeart} size={0.875} className="me-1" />
201262
All time
202263
</Button>
264+
<Button
265+
as={Link}
266+
to="/rankings/range"
267+
variant="outline-secondary"
268+
className="d-grid d-inline-flex align-items-center ps-1 vibe-border"
269+
size="sm"
270+
style={{ "--vibe": vibe }}
271+
>
272+
<Icon path={mdiCalendarRange} size={0.875} className="me-1" />
273+
Custom date range
274+
</Button>
203275
{scanDate("y") || scanDate("m") || scanDate("w") || scanDate("d") ? <hr className="m-0" /> : null}
204276
{scanDate("w") ? (
205277
<Button
206278
as={Link}
207-
to={`/rankings/y/${params.y}/m/${params.m}/d/${params.d}/week`}
279+
to={`/rankings/y/${stdParams.y}/m/${stdParams.m}/d/${stdParams.d}/week`}
208280
variant="outline-secondary"
209281
className="d-grid d-inline-flex align-items-center ps-1 vibe-border"
210282
size="sm"
@@ -217,7 +289,7 @@ export default function Rankings() {
217289
{scanDate("d") ? (
218290
<Button
219291
as={Link}
220-
to={`/rankings/y/${params.y}/m/${params.m}/d/${params.d}`}
292+
to={`/rankings/y/${stdParams.y}/m/${stdParams.m}/d/${stdParams.d}`}
221293
variant="outline-secondary"
222294
className="d-grid d-inline-flex align-items-center ps-1 vibe-border"
223295
size="sm"
@@ -230,27 +302,27 @@ export default function Rankings() {
230302
{scanDate("m") ? (
231303
<Button
232304
as={Link}
233-
to={`/rankings/y/${params.y}/m/${params.m}`}
305+
to={`/rankings/y/${stdParams.y}/m/${stdParams.m}`}
234306
variant="outline-secondary"
235307
className="d-grid d-inline-flex align-items-center ps-1 vibe-border"
236308
size="sm"
237309
style={{ "--vibe": vibe }}
238310
>
239311
<Icon path={mdiCalendarMonth} size={0.875} className="me-1" />
240-
For {new Date(params.y, params.m - 1).toLocaleDateString("en-US", { month: "long" })}
312+
For {new Date(stdParams.y, stdParams.m - 1).toLocaleDateString("en-US", { month: "long" })}
241313
</Button>
242314
) : null}
243315
{scanDate("y") ? (
244316
<Button
245317
as={Link}
246-
to={`/rankings/y/${params.y}`}
318+
to={`/rankings/y/${stdParams.y}`}
247319
variant="outline-secondary"
248320
className="d-grid d-inline-flex align-items-center ps-1 vibe-border"
249321
size="sm"
250322
style={{ "--vibe": vibe }}
251323
>
252324
<Icon path={mdiCalendarCheck} size={0.875} className="me-1" />
253-
For {params.y}
325+
For {stdParams.y}
254326
</Button>
255327
) : null}
256328
</div>
@@ -271,7 +343,7 @@ export default function Rankings() {
271343
link={`/identity/${item.nickname}`}
272344
shot={portraitProvider(item.mail)}
273345
head={item.nickname}
274-
body={`Collected ${item.badges} badge(s) ${y || m || d || isweekly ? `during this period • Global rank #${item.rank.global}` : ""}`}
346+
body={`Collected ${item.badges} badge(s) ${y || m || d || isweekly || (isCustomRange && rangeStart && rangeEnd) ? `during this period • Global rank #${item.rank.global}` : ""}`}
275347
hand={
276348
<Badge className="monoelem vibe-badge" style={{ "--vibe": vibe }}>
277349
#{item.rank.period}

tahrir/templates/explore.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ <h1 class="section-header">Reports</h1>
156156
<li>Top badge earners <a href="{{ url_for(".report_year_month_day", year=example_date.year, month=example_date.month, day=example_date.day) }}">for just the {{ example_date.strftime("%x") }}</a></li>
157157
<li>Top badge earners <a href="{{ url_for(".report_year_week", year=example_date.year, week=example_date.isocalendar().week) }}">for week {{ example_date.strftime("%W") }} of {{ example_date.year }}</a></li>
158158
<li>Top badge earners <a href="{{ url_for(".report") }}">for just this week</a></li>
159+
<li>Top badge earners for a <a href="{{ url_for(".report_custom_range") }}">custom date range</a> (start and end, calendar picker)</li>
159160
</ul>
160161
</div> <!-- End padded content. -->
161162
</div> <!-- End shadow. -->

tahrir/templates/report_range.html

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{% from '_functions.html' import avatar_thumbnail, pluralize with context %}
2+
{% extends '_base.html' %}
3+
4+
{% block body %}
5+
<div class="page">
6+
<div class="grid-100">
7+
<div class="shadow">
8+
<h1 class="section-header">Rising stars: custom period</h1>
9+
<div class="report-range padded-content">
10+
<p>Pick a start and end date (inclusive). Dates use the ISO form <strong>YYYY-MM-DD</strong> in the picker.</p>
11+
<p>Looking for the <a href="{{ url_for('tahrir.leaderboard') }}">all-time leaderboard</a> or <a href="{{ url_for('tahrir.explore') }}">other report links</a>?</p>
12+
13+
{% if error %}
14+
<p><strong style="color: #a40000;">{{ error }}</strong></p>
15+
{% endif %}
16+
17+
<form method="get" action="{{ url_for('tahrir.report_custom_range') }}">
18+
<label>Start
19+
<input type="date" name="start" value="{{ start_value }}" required>
20+
</label>
21+
<label>End
22+
<input type="date" name="end" value="{{ end_value }}" required>
23+
</label>
24+
<input type="submit" value="Show leaderboard">
25+
</form>
26+
27+
{% if user_to_rank is not none %}
28+
<p>Showing results for <strong>{{ start_date }}</strong> through <strong>{{ stop_date }}</strong> (inclusive).</p>
29+
<table>
30+
{% for person, stats in (user_to_rank.items()|list)[:25] %}
31+
<tr>
32+
<td style="width: 20px;">
33+
<span class="big-text">#{{ stats['rank'] }}</span>
34+
</td>
35+
<td style="width: 100px;">
36+
<small>(#{{ person.rank }} all time)</small>
37+
</td>
38+
<td style="width: 64px;">{{ avatar_thumbnail(person, 64, 33) }}</td>
39+
<td>
40+
<a href="{{ url_for('tahrir.user', user_id=person.nickname or person.id) }}">{{ person.nickname }}</a>
41+
earned <strong>{{ user_to_rank[person]['badges'] }}</strong>
42+
{{ pluralize("badge", user_to_rank[person]['badges']) }}
43+
during this period.
44+
</td>
45+
</tr>
46+
{% endfor %}
47+
</table>
48+
{% endif %}
49+
</div>
50+
</div>
51+
</div>
52+
<div class="clear spacer"></div>
53+
</div>
54+
{% endblock %}

tahrir/utils/date_time.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@
44
import dateutil.relativedelta
55

66

7+
def last_day_of_calendar_month(year: int, month: int) -> date:
8+
"""Return the last calendar day of the given month.
9+
10+
Used for monthly leaderboard/report ranges so HTML and JSON paths stay consistent.
11+
"""
12+
first = date(year, month, 1)
13+
nudge = first + timedelta(days=32)
14+
first_next_month = nudge.replace(day=1)
15+
return first_next_month - timedelta(days=1)
16+
17+
718
def get_start_week(year=None, month=None, day=None):
819
"""For a given date, retrieve the day the week started
920

0 commit comments

Comments
 (0)