feat: enhance student dashboard with points, badges, and activity chart#1022
feat: enhance student dashboard with points, badges, and activity chart#1022ayesha1145 wants to merge 1 commit intoalphaonelabs:mainfrom
Conversation
👀 Peer Review RequiredHi @ayesha1145! This pull request does not yet have a peer review. Before this PR can be merged, please request a review from one of your peers:
Thank you for contributing! 🎉 |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
Note
|
| Cohort / File(s) | Summary |
|---|---|
Student Dashboard Backend web/views.py |
Added Points and TruncDate imports. student_dashboard computes total_points, aggregates Points by day for the last 30 days into chart_labels/chart_data, and fetches recent_badges (limit 6) and total_badges, exposing these context keys. |
Student Dashboard Frontend web/templates/dashboard/student.html |
Added UI cards for Total Points and Badges Earned, injected chart_labels/chart_data via json_script, included Chart.js and a bar chart init script for a 30-day chart, and added a Recent Badges grid. Note: the chart+badges block appears twice (duplicate canvases/scripts). |
Sequence Diagram
sequenceDiagram
participant Client
participant DjangoView as Django View
participant DB as Database
participant Template
participant ChartJS as Chart.js
Client->>DjangoView: GET /student_dashboard
DjangoView->>DB: Query Points (user), aggregate by date (last 30 days) using TruncDate
DB-->>DjangoView: Points per day
DjangoView->>DB: Query UserBadge (user, latest 6) and total badges
DB-->>DjangoView: Recent badges + totals
DjangoView->>Template: Render with context (total_points, chart_labels, chart_data, recent_badges, total_badges)
Template->>ChartJS: Provide labels/data and initialize bar chart in browser
ChartJS-->>Client: Render chart
Estimated code review effort
🎯 3 (Moderate) | ⏱️ ~25 minutes
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
| Check name | Status | Explanation |
|---|---|---|
| Description Check | ✅ Passed | Check skipped - CodeRabbit’s high-level summary is enabled. |
| Title check | ✅ Passed | The title accurately describes the main changes: adding points tracking, badge display, and a 30-day activity chart to the student dashboard. |
| Docstring Coverage | ✅ Passed | Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. |
✏️ Tip: You can configure your own custom pre-merge checks in the settings.
✨ Finishing Touches
🧪 Generate unit tests (beta)
- Create PR with unit tests
- Post copyable unit tests in a comment
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Comment @coderabbitai help to get the list of available commands and usage tips.
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@web/templates/dashboard/student.html`:
- Around line 55-75: The new stat cards use out-of-palette purple utilities and
lack dark-mode text classes: update the badge card and icon classes to the
project palette (e.g., replace bg-purple-100/dark:bg-purple-900 and
text-purple-* with bg-teal-100 dark:bg-teal-900 and text-teal-500
dark:text-teal-300 or other approved teal variants) and add explicit light/dark
text color classes to the stat values (the <h3> elements rendering {{
total_points }} and {{ total_badges }}) such as text-gray-900 dark:text-gray-200
(or dark:text-white) so counts remain high-contrast in dark mode; apply the same
palette/dark-mode fixes to the recent-badge section referenced in the comment.
- Around line 237-265: The new analytics blocks (the Points Chart canvas with id
"pointsChart" and the {% if recent_badges %} badges section) are currently
outside the main dashboard container, causing them to span full width; move
these blocks inside the existing container wrapper (the div using container
mx-auto px-4 py-8) by relocating these sections before the container's closing
tag or by moving the container closing tag to after the chart and badges block
so they inherit the container mx-auto px-4 (and px/y padding) classes; ensure
HTML remains well-formed (matching opening/closing divs) and that the
pointsChart and recent_badges blocks keep their markup and accessibility
attributes unchanged.
- Line 242: Add accessible attributes to the chart canvas: update the <canvas
id="pointsChart"> element to include role="img" and a descriptive aria-label
(e.g., aria-label="Student points over time chart") so screen readers can
announce it. Replace direct inlined JS data embedding (the template variables
used around lines 273 and 276) with Django's json_script filter: output each
chart data variable via json_script with unique script IDs (e.g., "points-data",
"labels-data") and then parse those script tag contents in the chart
initialization code to build the Chart.js dataset; keep referencing the canvas
by its id pointsChart when creating the chart.
In `@web/views.py`:
- Around line 2650-2661: Add a unit test for the dashboard view that verifies
the new context keys chart_labels, chart_data, recent_badges and total_badges
handle edge cases: create scenarios with (1) empty learning/activity history,
(2) multiple badge awards on the same day, and (3) a badge awarded exactly at
the 30-day cutoff; call the view (or use the test client) to retrieve the
response context and assert chart_labels/chart_data reflect empty or aggregated
data as expected, recent_badges includes the correct badges within 30 days, and
total_badges counts all awards including the cutoff one; implement tests
referencing the dashboard view function in web.views and the context keys
chart_labels, chart_data, recent_badges, total_badges so future changes to these
paths will be caught.
- Around line 2631-2644: The query uses awarded_at__date__gte which prevents
using the (user, awarded_at) index; change the filter to a datetime range using
awarded_at__gte=thirty_days_ago and awarded_at__lt=thirty_days_tomorrow (or
thirty_days_ago + timedelta(days=30)) on the Points queryset (points_qs) and
replace the values/annotate grouping that relied on awarded_at__date with
TruncDate(awarded_at) (import TruncDate from django.db.models.functions) to
produce a date key for aggregation and then build points_by_date and the
chart_labels/chart_data the same way from those truncated dates.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository: alphaonelabs/coderabbit/.coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 85196e61-41c5-41a9-9459-c6102312b18a
📒 Files selected for processing (2)
web/templates/dashboard/student.htmlweb/views.py
342a4ed to
d6e43a9
Compare
💬 Unresolved Review ConversationsHi @ayesha1145! 👋 This pull request currently has 1 unresolved review conversation. Please address all review feedback and push a new commit to resolve them before this PR can be merged. Steps to resolve:
Once all conversations are resolved, this notice will be removed automatically. Thank you! 🙏 |
|
Addressed CodeRabbit feedback:
|
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
web/views.py (1)
2629-2646:⚠️ Potential issue | 🟠 MajorConstrain the chart query to the actual 30-day window.
Line 2633 currently applies only a lower bound, so this query returns all points from the last 30 days and any future-dated rows. That means the dashboard is not strictly showing the requested 30-day window, and because
points_by_dateis keyed by"%b %d"strings, an out-of-window row with the same month/day can overwrite the in-window total. Add an upper bound (< tomorrow at 00:00) and keep the aggregation map keyed by the truncateddateobject until you build the labels.♻️ Proposed fix
# Points over last 30 days for chart today = timezone.now().date() thirty_days_ago = today - timedelta(days=29) - start_datetime = timezone.make_aware(timezone.datetime.combine(thirty_days_ago, timezone.datetime.min.time())) + start_datetime = timezone.make_aware(datetime.combine(thirty_days_ago, datetime.min.time())) + end_datetime = timezone.make_aware(datetime.combine(today + timedelta(days=1), datetime.min.time())) points_qs = ( - Points.objects.filter(user=request.user, awarded_at__gte=start_datetime) + Points.objects.filter( + user=request.user, + awarded_at__gte=start_datetime, + awarded_at__lt=end_datetime, + ) .annotate(day=TruncDate("awarded_at")) .values("day") .annotate(total=Sum("amount")) .order_by("day") ) - points_by_date = {entry["day"].strftime("%b %d"): entry["total"] for entry in points_qs} + points_by_date = {entry["day"]: entry["total"] for entry in points_qs} chart_labels = [] chart_data = [] for i in range(30): day = thirty_days_ago + timedelta(days=i) - label = day.strftime("%b %d") - chart_labels.append(label) - chart_data.append(points_by_date.get(label, 0)) + chart_labels.append(day.strftime("%b %d")) + chart_data.append(points_by_date.get(day, 0))🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/views.py` around lines 2629 - 2646, Query currently only uses awarded_at__gte so it includes future-dated rows and string-keying by "%b %d" lets those overwrite in-window totals; add an upper bound to the ORM filter (awarded_at__lt) using tomorrow at 00:00 (compute tomorrow = timezone.now().date() + timedelta(days=1) and make_aware combine as you did for start_datetime) and keep the aggregation map keyed by the truncated date object (use entry["day"] as the dict key rather than entry["day"].strftime) then when building chart_labels/chart_data format the day to "%b %d" for labels and lookup totals from points_by_date using the date object; reference Points, request.user, awarded_at__gte, TruncDate, points_by_date, chart_labels and chart_data to locate changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@web/templates/dashboard/student.html`:
- Around line 251-261: The template currently checks ub.badge.icon which doesn't
exist on the Badge model so the fallback fa-award is always shown; update the
recent_badges block to render the Badge.image when present (use
ub.badge.image.url via an <img> tag) and only fall back to the Font Awesome icon
if no image exists, referencing the same loop and ub.badge symbols;
alternatively, if you prefer icon classes add an icon field to the Badge model
and migrate, then use ub.badge.icon in the template; also add the dark mode
class (dark:text-yellow-300) to the fallback icon to match styling.
- Line 269: Add Subresource Integrity and crossorigin attributes to the Chart.js
CDN script tag in the template (the <script
src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.min.js"></script>
element) by copying the SRI hash for Chart.js 4.4.0 from the CDN provider and
adding integrity="sha384-..." plus crossorigin="anonymous" so the browser
verifies the file and matches the pattern used for other CDN scripts (jQuery,
Tailwind, Sentry) in your templates.
---
Duplicate comments:
In `@web/views.py`:
- Around line 2629-2646: Query currently only uses awarded_at__gte so it
includes future-dated rows and string-keying by "%b %d" lets those overwrite
in-window totals; add an upper bound to the ORM filter (awarded_at__lt) using
tomorrow at 00:00 (compute tomorrow = timezone.now().date() + timedelta(days=1)
and make_aware combine as you did for start_datetime) and keep the aggregation
map keyed by the truncated date object (use entry["day"] as the dict key rather
than entry["day"].strftime) then when building chart_labels/chart_data format
the day to "%b %d" for labels and lookup totals from points_by_date using the
date object; reference Points, request.user, awarded_at__gte, TruncDate,
points_by_date, chart_labels and chart_data to locate changes.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository: alphaonelabs/coderabbit/.coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: f2c9fd16-651f-4038-9c6b-4f6c0f3b72fa
📒 Files selected for processing (2)
web/templates/dashboard/student.htmlweb/views.py
d6e43a9 to
783dc9b
Compare
|
Addressed feedback: |
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
web/views.py (1)
2631-2646:⚠️ Potential issue | 🟡 MinorClose the chart window on the upper bound and keep date keys unformatted.
Right now the queryset only applies
awarded_at__gte=start_datetime, thenpoints_by_datecollapses grouped dates to"%b %d"strings. If future-datedPointsrows exist from fixtures, imports, or manual corrections, a later row like2027-02-12can overwrite the2026-02-12bucket and show the wrong value in this “last 30 days” chart.Suggested fix
today = timezone.now().date() thirty_days_ago = today - timedelta(days=29) - start_datetime = timezone.make_aware(timezone.datetime.combine(thirty_days_ago, timezone.datetime.min.time())) + start_datetime = timezone.make_aware(datetime.combine(thirty_days_ago, datetime.min.time())) + end_datetime = timezone.make_aware(datetime.combine(today + timedelta(days=1), datetime.min.time())) points_qs = ( - Points.objects.filter(user=request.user, awarded_at__gte=start_datetime) + Points.objects.filter(user=request.user, awarded_at__gte=start_datetime, awarded_at__lt=end_datetime) .annotate(day=TruncDate("awarded_at")) .values("day") .annotate(total=Sum("amount")) .order_by("day") ) - points_by_date = {entry["day"].strftime("%b %d"): entry["total"] for entry in points_qs} + points_by_date = {entry["day"]: entry["total"] for entry in points_qs} chart_labels = [] chart_data = [] for i in range(30): day = thirty_days_ago + timedelta(days=i) label = day.strftime("%b %d") chart_labels.append(label) - chart_data.append(points_by_date.get(label, 0)) + chart_data.append(points_by_date.get(day, 0))🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/views.py` around lines 2631 - 2646, The query and grouping currently only filter with awarded_at__gte and collapse dates to "%b %d" strings, which lets future-dated rows collide; change the time filter to a closed-open range by computing an end_datetime (e.g., end_datetime = start_datetime + timedelta(days=30)) and use awarded_at__gte=start_datetime, awarded_at__lt=end_datetime (or awarded_at__range with the proper end) on Points.objects.filter(...), keep the TruncDate("awarded_at") values as date objects in points_by_date (map entry["day"] to entry["total"] instead of entry["day"].strftime), and only format the label strings when appending to chart_labels/chart_data by deriving the date for each day in the 30-day window and looking up points_by_date[day.date()] (or points_by_date[day]) to avoid cross-year collisions; update references: start_datetime, Points.objects.filter(...), TruncDate, points_by_date, chart_labels, chart_data.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@web/templates/dashboard/student.html`:
- Around line 253-258: The img alt currently duplicates the visible label ({{
ub.badge.name }}) causing screen readers to announce the badge name twice;
update the image tag for decorative images (when ub.badge.image exists) to use
an empty alt attribute (alt="") so the visible <p>{{ ub.badge.name }}</p>
remains the accessible label; locate the conditional block that references
ub.badge.image and ub.badge.name and change only the img alt value to an empty
string.
---
Duplicate comments:
In `@web/views.py`:
- Around line 2631-2646: The query and grouping currently only filter with
awarded_at__gte and collapse dates to "%b %d" strings, which lets future-dated
rows collide; change the time filter to a closed-open range by computing an
end_datetime (e.g., end_datetime = start_datetime + timedelta(days=30)) and use
awarded_at__gte=start_datetime, awarded_at__lt=end_datetime (or
awarded_at__range with the proper end) on Points.objects.filter(...), keep the
TruncDate("awarded_at") values as date objects in points_by_date (map
entry["day"] to entry["total"] instead of entry["day"].strftime), and only
format the label strings when appending to chart_labels/chart_data by deriving
the date for each day in the 30-day window and looking up
points_by_date[day.date()] (or points_by_date[day]) to avoid cross-year
collisions; update references: start_datetime, Points.objects.filter(...),
TruncDate, points_by_date, chart_labels, chart_data.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository: alphaonelabs/coderabbit/.coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 3c62090d-01cb-46eb-9874-7f1fd96d3519
📒 Files selected for processing (2)
web/templates/dashboard/student.htmlweb/views.py
783dc9b to
d90d299
Compare
|
Fixed: badge image now uses alt="" to avoid announcing the badge name twice for screen readers — the visible text label below carries the accessible name. |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
web/views.py (1)
2631-2646:⚠️ Potential issue | 🟡 MinorBound the chart query to the 30-day window and keep date keys until render time.
This query only has a lower bound, while
points_by_datecollapses rows onto%b %dstrings. If any future-datedPointsrow slips in, it can overwrite the in-window bucket with the same month/day label and skew the chart. Filter through the start of tomorrow and key the lookup by the truncateddateobject instead.Suggested fix
today = timezone.now().date() thirty_days_ago = today - timedelta(days=29) - start_datetime = timezone.make_aware(timezone.datetime.combine(thirty_days_ago, timezone.datetime.min.time())) + start_datetime = timezone.make_aware(datetime.combine(thirty_days_ago, datetime.min.time())) + end_datetime = timezone.make_aware(datetime.combine(today + timedelta(days=1), datetime.min.time())) points_qs = ( - Points.objects.filter(user=request.user, awarded_at__gte=start_datetime) + Points.objects.filter( + user=request.user, + awarded_at__gte=start_datetime, + awarded_at__lt=end_datetime, + ) .annotate(day=TruncDate("awarded_at")) .values("day") .annotate(total=Sum("amount")) .order_by("day") ) - points_by_date = {entry["day"].strftime("%b %d"): entry["total"] for entry in points_qs} + points_by_date = {entry["day"]: entry["total"] for entry in points_qs} chart_labels = [] chart_data = [] for i in range(30): day = thirty_days_ago + timedelta(days=i) - label = day.strftime("%b %d") - chart_labels.append(label) - chart_data.append(points_by_date.get(label, 0)) + chart_labels.append(day.strftime("%b %d")) + chart_data.append(points_by_date.get(day, 0))🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/views.py` around lines 2631 - 2646, The query currently only filters by a lower bound and keys points_by_date by formatted strings, so future-dated Points can collide; change the filter to bound the window by adding an upper bound (e.g., end_datetime = start of tomorrow and use awarded_at__lt=end_datetime on the Points queryset), key points_by_date by the truncated date object returned by TruncDate (entry["day"]) instead of entry["day"].strftime(...), and in the loop that builds chart_labels/chart_data (referencing points_qs, points_by_date, thirty_days_ago, TruncDate) use the date objects to look up totals and defer any strftime formatting of labels until render time.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@web/templates/dashboard/student.html`:
- Around line 238-240: The chart heading icon uses the class "text-teal-500"
without a dark-mode variant; update the icon element (the <i> with classes "fas
fa-chart-bar text-teal-500 mr-2") to include an appropriate dark: prefixed color
(for example dark:text-teal-300 or another accessible teal shade) so the icon
remains visible and consistent when dark mode (dark:bg-gray-800) is active.
---
Duplicate comments:
In `@web/views.py`:
- Around line 2631-2646: The query currently only filters by a lower bound and
keys points_by_date by formatted strings, so future-dated Points can collide;
change the filter to bound the window by adding an upper bound (e.g.,
end_datetime = start of tomorrow and use awarded_at__lt=end_datetime on the
Points queryset), key points_by_date by the truncated date object returned by
TruncDate (entry["day"]) instead of entry["day"].strftime(...), and in the loop
that builds chart_labels/chart_data (referencing points_qs, points_by_date,
thirty_days_ago, TruncDate) use the date objects to look up totals and defer any
strftime formatting of labels until render time.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository: alphaonelabs/coderabbit/.coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 79a888b1-2c85-4f27-b30e-05c2f7144e92
📒 Files selected for processing (2)
web/templates/dashboard/student.htmlweb/views.py
d90d299 to
b258efa
Compare
|
Fixed: added dark:text-teal-300 to the chart heading icon for dark mode consistency. |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@web/templates/dashboard/student.html`:
- Around line 267-296: The template embeds Chart.js and the chart initialization
inline instead of using the project's established template blocks; move the
Chart.js <script> include into the {% block extra_head %} and relocate the JSON
script tags (chart_labels|json_script:"points-chart-labels",
chart_data|json_script:"points-chart-data") plus the inline chart initialization
into {% block extra_js %} so Chart.js loads in the head and the initialization
runs in the page JS block, keeping consistency with base.html and other
dashboard templates (references: chart_labels, chart_data, pointsChart,
extra_head, extra_js).
- Line 248: The Recent Badges heading icon (<i class="fas fa-award ...">) lacks
a dark-mode variant; update the icon's class list by adding a dark: prefixed
color (e.g., dark:text-yellow-400) alongside text-yellow-500 so the award icon
matches dark theme styling (modify the <i> element with class "fas fa-award
text-yellow-500 mr-2" to include the dark: variant).
- Line 269: Replace the incorrect SRI hash on the Chart.js script tag in the
student dashboard template by updating the integrity attribute value on the
Chart.js <script> element (the line that loads "chart.umd.min.js") to the
official cdnjs SHA-512 value; specifically change the existing integrity string
to
"sha512-SIMGYRUjwY8+gKg7nn9EItdD8LCADSDfJNutF9TPrvEo86sQmFMh6MyralfIyhADlajSxqc7G0gs7+MwWF/ogQ=="
so the browser integrity check succeeds.
In `@web/views.py`:
- Line 2649: The query building recent_badges is non-deterministic when multiple
UserBadge.awarded_at values tie; update the UserBadge queryset (recent_badges)
to include a stable tie-breaker in order_by (e.g., "-id") so the slice is
deterministic: keep select_related("badge") and order by "-awarded_at" then
"-id" before slicing to 6.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository: alphaonelabs/coderabbit/.coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 38256435-2112-49e6-b5d3-e8fbf3a338d2
📒 Files selected for processing (2)
web/templates/dashboard/student.htmlweb/views.py
web/views.py
Outdated
| chart_data.append(points_by_date.get(label, 0)) | ||
|
|
||
| # Badges | ||
| recent_badges = UserBadge.objects.filter(user=request.user).select_related("badge").order_by("-awarded_at")[:6] |
There was a problem hiding this comment.
Make the recent-badges slice deterministic.
If multiple UserBadge rows share the same awarded_at, ordering by that field alone makes the top 6 unstable. Add a tie-breaker such as -id so the dashboard doesn’t reshuffle badges between renders.
Suggested fix
- recent_badges = UserBadge.objects.filter(user=request.user).select_related("badge").order_by("-awarded_at")[:6]
+ recent_badges = (
+ UserBadge.objects.filter(user=request.user)
+ .select_related("badge")
+ .order_by("-awarded_at", "-id")[:6]
+ )🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/views.py` at line 2649, The query building recent_badges is
non-deterministic when multiple UserBadge.awarded_at values tie; update the
UserBadge queryset (recent_badges) to include a stable tie-breaker in order_by
(e.g., "-id") so the slice is deterministic: keep select_related("badge") and
order by "-awarded_at" then "-id" before slicing to 6.
b258efa to
da1e2f1
Compare
|
Addressed feedback:
|
Enhances the existing student dashboard with points tracking, badge display, and a 30-day activity chart.
Changes:
Features:
Enhanced Student Dashboard with Points, Badges, and 30‑Day Activity Chart
This PR adds gamification and recent-activity visualization to the student dashboard: total points, badge counts, a recent badges grid, and a 30‑day points bar chart.
Key Changes
Backend (web/views.py)
Pointsmodel andTruncDate.total_points(sum of Points.amount, defaults to 0).chart_labels,chart_data) using a datetime-range filter (start_datetime) plusTruncDatefor per-day aggregation to preserve index efficiency.recent_badgesand computestotal_badges.total_points,chart_labels,chart_data,recent_badges,total_badges.Frontend (web/templates/dashboard/student.html)
json_script. Canvas includes role and aria-label for accessibility. Chart.js CDN tag includes an SRI integrity hash.<img>uses alt="" to avoid duplicate announcements (name shown as text).json_script(avoids |safe), adds SRI, and improves canvas accessibility attributes.Impact / Notes for Review
TruncDateto remain index-friendly; verify timezone handling meets app expectations (start_datetime uses timezone-aware construction).