[templates] Add calendar view
All checks were successful
build & deploy / test (push) Successful in 1m52s
build & deploy / build-and-deploy (push) Successful in 35s

This commit is contained in:
2026-05-27 13:10:12 -04:00
parent fd95f1e686
commit 99789e5477
6 changed files with 408 additions and 9 deletions

View File

@ -93,9 +93,15 @@ fetching and simple saving.
:LOGBOOK:
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
:END:
* Backlog [22/42]
** TODO Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
* Backlog [26/42]
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
:PROPERTIES:
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
:END:
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
:PROPERTIES:
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
:END:
** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement:
:PROPERTIES:
:ID: ab31fdc3-359c-1b1d-6b9d-546b476021ba
@ -391,16 +397,32 @@ CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
}
#+end_src
** TODO [#C] User should be able to enable auto trail tracking via amail reader with Garmin LiveTrack URLs :vrobbler:trails:project:feature:personal:
** TODO [#C] Allow users to see tasks on calendar view :vrobbler:personal:project:templates:feature:
https://codepen.io/oliviale/pen/QYqybo
** TODO [#C] Come up with a possible flow using WebDAV and super-productivity for tasks :personal:feature:project:vrobbler:tasks:
:PROPERTIES:
:ID: 133bcf71-078f-4efa-a029-1eae4b4d146d
:END:
** TODO [#B] Add importer class for IMAP imports :vrobbler:feature:imap:importers:project:personal:
:PROPERTIES:
:ID: b1426d92-2feb-4d15-9738-d5b7b0594f96
:END:
** TODO [#B] Add AllTrails as a source for Trail data :vrobbler:trails:feature:personal:project:
:PROPERTIES:
:ID: 39313362-cdfe-46e7-bbd4-9139a65c0b3c
:END:
*** Description
Pretty clear, I would love to make trails more useful. Historically I wasn't
hiking a lot, which made the source for this a bit silly. But it's clear that
AllTrails is the best source, though having TrailForks is nice to.
** TODO [#B] Add `garmin_activity_id` to the TrailMetadataLog class :vrobbler:trails:feature:personal:project:
Would be nice to have some loose connection to the actual event in my Garmin profile.
:PROPERTIES:
:ID: 5a4fb0f8-0555-40ec-b06f-93c26bd686f4
:END:
*** Description
Would be nice to have some loose connection to the actual event in my Garmin
profile.
** TODO [#B] Explore a way to add metadata editing to scrobbles after saving :vrobbler:spike:scrobbling:personal:project:
Could be as simple as a JSON form on the scrobble detail page (do I have have
@ -409,15 +431,19 @@ one of those yet?).
** TODO [#B] Add webdav syncing to retroarch imports :vrobbler:videogames:webdav:feature:project:personal:
** TODO [#B] Add CSV endpoint for book scrobbles that LibraryThing can ingest :personal:project:books:feature:export:
https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-library-thing-can-ingest-6X7QPMRp265xMXqg#comment-6X7QrXq6gJjMP4hg
** TODO [#B] Fix PuzzleLogData has no attribute form :vrobbler:puzzles:personal:project:logdata:
** TODO [#B] Scrape ComicBookRoundUp ratings for comic book metadata :vrobbler:books:feature:comicbook:personal:project:
- Note taken on [2025-09-25 Thu 10:51]
As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction
** TODO [#B] Add PuzzleLogData class with with_people and completed :vrobbler:feature:puzzles:logdata:personal:project:
** TODO [#A] Find page numbers for comic books from ComicVine :vrobbler:feature:books:personal:project:
:PROPERTIES:
:ID: 79f867c3-1288-4143-b6bf-2a452983ee9f
:END:
** TODO [#A] Fix koreader scrobble imports to use DST properly :vrobbler:personal:bug:books:imports:
:PROPERTIES:
:ID: 79758cba-a440-48b6-a637-efb88827acf2
:END:
- Note taken on [2025-09-25 Thu 10:37] \\
@ -431,11 +457,73 @@ https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-li
an hour for part of the year. Also, we'd need to adjust any old scrobbles that
took place with DST off to roll them back by an hour.
*** Description
This is a long-standing problem when daylight saving time takes effect. Time is
manually set on a KoReader device (or at least, always saved in local time). So
whatever time KoReader reports, we need to know, given the date and the user
profile's historic timezone, how many hours to adjust the KoReader time to get
to GMT to save it in the database.
** TODO [#A] When creating org-mode tasks, don't copy comments :vrobbler:bug:scrobbles:tasks:
** TODO Fix various artist album problem with Superwolves (track with multiple artists) :vrobbler:project:music:bug:artists:
:PROPERTIES:
:ID: 0c762d09-fc69-4e75-be40-7eaaf04f178e
:END:
*** Description
It would be nice to not duplicate comments that exist on a task when it's first scrobbled.
** TODO [#A] Fix various artist album problem with Superwolves (track with multiple artists) :vrobbler:project:music:bug:artists:
:PROPERTIES:
:ID: 590bc038-745f-710b-8272-4d8a3d2efa01
:END:
*** Description
We have an issue with tracks where there are two artists, like `Matt Sweeney &
Bonnie "Prince" Billy`
I think we need to allow creating a single artist with both names, so the Artist
is the full name. That said, the ampersand is usually used to split feature
artsits (I think) so I'm not sure how this would work reliably.
Also, it's possible musicbrainz does not have dual artist listings. So it's
possible the longer term solution is to allow multiple artists per track the way
we now allow tracks to be on multiple albums. We could deprecate the Artist FK
or at least make it optional, and then require a M2M between Track and Artist.
Then this one would be a Track by both `Matt Sweeney` and `Bonnie "Prince"
Billy`
** DONE [#A] Add CSS Grid calendar view for scrobbles :vrobbler:personal:project:templates:feature:
:PROPERTIES:
:ID: be915acf-d803-466a-8770-823819ebf2a9
:END:
*** Description
Calendar view at /scrobbles/calendar/ showing select media types (Tasks,
Birding, Food, Trails, VideoGames, Books) in a CSS Grid month layout.
- Emoji badges per scrobble on each day cell, hover reveals title
- Click emoji to navigate to scrobble detail
- Prev/Next month navigation
- Based on the CodePen CSS Grid calendar pattern:
https://codepen.io/oliviale/pen/QYqybo
** DONE [#C] Come up with a possible flow using WebDAV and super-productivity for tasks :personal:feature:project:vrobbler:tasks:
:PROPERTIES:
:ID: dd751edf-6192-2ed0-cd85-905f423f82b0
:END:
** DONE [#B] Fix PuzzleLogData has no attribute form :vrobbler:puzzles:personal:project:logdata:
:PROPERTIES:
:ID: cb8e697a-9fc2-c463-5d4d-fa15d3e833a0
:END:
** DONE [#B] Add PuzzleLogData class with with_people and completed :vrobbler:feature:puzzles:logdata:personal:project:
:PROPERTIES:
:ID: 1b38c8a8-86d3-b73d-c584-dfb0497970dc
:END:
** DONE Add weather lookup to the mood check-in flow :vrobbler:project:moods:feature:checkin:
:PROPERTIES:
:ID: 175678e2-bfe1-0833-f2a9-f03b9323db78

View File

@ -5,6 +5,7 @@ from tasks.webhooks import EmacsWebhookView, TodoistWebhookView
app_name = "scrobbles"
urlpatterns = [
path("calendar/", views.ScrobbleCalendarView.as_view(), name="calendar"),
path("search/", views.ScrobbleSearchView.as_view(), name="search"),
path("status/", views.ScrobbleStatusView.as_view(), name="status"),
path(

View File

@ -2,6 +2,7 @@ import calendar
import json
import logging
from datetime import datetime, timedelta
from uuid import uuid4
import pendulum
import pytz
@ -1281,6 +1282,129 @@ class EmbeddableTopArtistsWidget(TemplateView):
return context
class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
template_name = "scrobbles/calendar.html"
CALENDAR_MEDIA_TYPES = [
"Task",
"BirdingLocation",
"Food",
"Trail",
"VideoGame",
"Book",
"Mood",
]
MEDIA_EMOJI = {
"Task": "",
"BirdingLocation": "🐦",
"Food": "🍽️",
"Trail": "🥾",
"VideoGame": "🎮",
"Book": "📚",
"Mood": "🥰",
}
MONTH_COLORS = [
"#db7a7a", # Jan
"#db847a", # Feb
"#b0db7a", # Mar
"#7adb82", # Apr
"#7adbb3", # May
"#7ab6db", # Jun
"#7a8edb", # Jul
"#977adb", # Aug
"#c47adb", # Sep
"#db7ac5", # Oct
"#db7a90", # Nov
"#db7a7a", # Dec
]
def _day_color(self, month_index, day_num, total_days):
import colorsys
hue = (month_index - 1) * 30 / 360
lightness = 0.80 + (day_num / total_days) * 0.15
r, g, b = colorsys.hls_to_rgb(hue, lightness, 0.5)
return f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}"
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
today = timezone.localdate()
date_param = self.request.GET.get("date", today.isoformat()[:7])
try:
year, month = [int(x) for x in date_param.split("-")]
except (ValueError, IndexError):
year, month = today.year, today.month
_, total_days = calendar.monthrange(year, month)
month_start = datetime(year, month, 1).date()
month_end = datetime(year, month, total_days).date()
first_weekday = month_start.weekday()
prev_month = month_start - timedelta(days=1)
next_month = month_end + timedelta(days=1)
scrobbles = (
Scrobble.objects.filter(
user=self.request.user,
timestamp__date__gte=month_start,
timestamp__date__lte=month_end,
media_type__in=self.CALENDAR_MEDIA_TYPES,
)
.select_related("task", "birding_location", "food", "trail", "video_game", "book", "mood")
.order_by("timestamp")
)
day_map = {d: [] for d in range(1, total_days + 1)}
for scrobble in scrobbles:
day_map[scrobble.timestamp.day].append(scrobble)
missing_uuids = [s for s in scrobbles if not s.uuid]
if missing_uuids:
for scrobble in missing_uuids:
scrobble.uuid = uuid4()
Scrobble.objects.bulk_update(missing_uuids, ["uuid"])
calendar_days = []
month_color = self.MONTH_COLORS[(month - 1) % 12]
for day_num in range(1, total_days + 1):
day_scrobbles = []
for scrobble in day_map[day_num]:
day_scrobbles.append(
{
"uuid": scrobble.uuid,
"emoji": self.MEDIA_EMOJI.get(scrobble.media_type, "📌"),
"title": str(scrobble.media_obj) if scrobble.media_obj else scrobble.media_type,
"media_type": scrobble.media_type,
}
)
calendar_days.append({
"day": day_num,
"scrobbles": day_scrobbles,
"is_today": year == today.year and month == today.month and day_num == today.day,
"color": self._day_color(month, day_num, total_days),
})
ctx.update(
{
"year": year,
"month": month,
"month_name": month_start.strftime("%B"),
"total_days": total_days,
"first_weekday": first_weekday,
"calendar_days": calendar_days,
"prev_month": prev_month.isoformat()[:7],
"next_month": next_month.isoformat()[:7],
"today": today,
"current_month": today.isoformat()[:7],
"day_names": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"month_color": month_color,
}
)
return ctx
class ScrobbleSearchView(LoginRequiredMixin, TemplateView):
template_name = "scrobbles/scrobble_search.html"

View File

@ -0,0 +1,180 @@
{% extends "base.html" %}
{% load static %}
{% load humanize %}
{% block head_extra %}
<style>
html, body { height: 100%; margin: 0; padding: 0; }
header.navbar { display: none !important; }
#sidebarMenu { display: none !important; }
.container-fluid, .container-fluid > .row, .container-fluid > .row > div {
height: 100%;
padding: 0;
margin: 0;
max-width: 100%;
}
.home-btn {
position: fixed;
top: 12px;
left: 12px;
z-index: 100;
background: rgba(255,255,255,0.85);
border: 1px solid #ddd;
border-radius: 20px;
padding: 6px 16px;
font-size: 0.85rem;
text-decoration: none;
color: #555;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
width: auto;
}
.home-btn:hover {
background: #fff;
color: #333;
}
.calendar-wrapper {
height: 100vh;
display: flex;
flex-direction: column;
padding: 12px;
max-width: 100%;
overflow: hidden;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 1px;
background: {{ month_color }}55;
border: 1px solid {{ month_color }}44;
border-radius: 4px;
overflow: hidden;
flex: 1;
}
.month-banner {
background: {{ month_color }};
color: rgba(255,255,255,0.7);
font: 900 2.5rem "Montserrat", sans-serif;
text-transform: uppercase;
padding: 24px 20px;
text-align: center;
grid-column: 1 / -1;
text-shadow: 0 2px 4px rgba(0,0,0,0.1);
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
}
.month-banner a {
color: rgba(255,255,255,0.5);
text-decoration: none;
font-size: 1.5rem;
line-height: 1;
}
.month-banner a:hover {
color: #fff;
}
.day-header {
background: {{ month_color }};
color: rgba(255,255,255,0.8);
padding: 10px 4px;
text-align: center;
font-weight: 700;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.day-cell {
background: {{ month_color }}18;
min-height: 100px;
padding: 6px;
position: relative;
}
.day-cell--today {
background: #fffde7;
}
.day-number {
font-size: 0.85rem;
font-weight: 700;
color: #555;
margin-bottom: 4px;
}
.day-cell--today .day-number {
color: #446e9b;
background: #e8f0fe;
display: inline-block;
padding: 0 6px;
border-radius: 50%;
}
.event-card {
display: block;
background: rgba(255,255,255,0.6);
border-radius: 12px;
padding: 3px 8px;
margin: 3px 0;
font-size: 0.8rem;
text-decoration: none;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-card:hover {
background: rgba(255,255,255,0.9);
color: #000;
}
.calendar-footer {
background: {{ month_color }};
grid-column: 1 / -1;
padding: 12px;
text-align: center;
}
.calendar-footer a {
color: rgba(255,255,255,0.7);
text-decoration: none;
margin: 0 8px;
font-size: 0.9rem;
}
.calendar-footer a:hover {
color: #fff;
}
</style>
{% endblock %}
{% block content %}
<a href="/" class="home-btn">Home</a>
<div class="calendar-wrapper">
<div class="calendar-grid">
<div class="month-banner">
<a href="?date={{ prev_month }}">&larr;</a>
{{ month_name }} {{ year }}
<a href="?date={{ next_month }}">&rarr;</a>
</div>
{% for name in day_names %}
<div class="day-header">{{ name }}</div>
{% endfor %}
{% for _ in ""|ljust:first_weekday %}
<div class="day-cell" style="background:transparent;"></div>
{% endfor %}
{% for cd in calendar_days %}
<div class="day-cell{% if cd.is_today %} day-cell--today{% endif %}"
style="background:{% if not cd.is_today %}{{ cd.color }}{% endif %};">
<div class="day-number">{{ cd.day }}</div>
{% for s in cd.scrobbles %}
<a href="{% url 'scrobbles:detail' uuid=s.uuid %}"
class="event-card"
title="{{ s.title }} — {{ s.media_type }}">{{ s.emoji }} {{ s.title }}</a>
{% endfor %}
</div>
{% endfor %}
<div class="calendar-footer">
<a href="?date={{ prev_month }}">&larr; Previous</a>
<a href="?date={{ current_month }}">Today</a>
<a href="?date={{ next_month }}">Next &rarr;</a>
</div>
</div>
</div>
{% endblock %}

View File

@ -64,6 +64,9 @@
placeholder="Search..."
style="width: 150px;">
</form>
<div class="btn-group me-2">
<a href="{% url 'scrobbles:calendar' %}" class="btn btn-sm btn-outline-secondary">Calendar</a>
</div>
<div class="btn-group me-2">
<a href="{% url 'admin:index' %}" class="btn btn-sm btn-outline-secondary">Admin</a>
</div>

View File

@ -54,6 +54,9 @@
<h1 class="h2">Search Scrobbles</h1>
<div class="btn-toolbar mb-2 mb-md-0">
{% if user.is_authenticated %}
<div class="btn-group me-2">
<a href="{% url 'scrobbles:calendar' %}" class="btn btn-sm btn-outline-secondary">Calendar</a>
</div>
<div class="btn-group me-2">
<a href="{% url 'admin:index' %}" class="btn btn-sm btn-outline-secondary">Admin</a>
</div>