[birds] Add charts for birds
All checks were successful
build & deploy / test (push) Successful in 1m55s
build & deploy / build-and-deploy (push) Successful in 29s

This commit is contained in:
2026-05-15 15:05:46 -04:00
parent 97f35f62b8
commit 2b04a17d77
4 changed files with 199 additions and 1 deletions

View File

@ -1,4 +1,4 @@
from charts.views import ChartRecordView, SpotifyTracksView
from charts.views import BirdsChartView, ChartRecordView, SpotifyTracksView
from django.urls import path
app_name = "charts"
@ -6,4 +6,5 @@ app_name = "charts"
urlpatterns = [
path("charts/", ChartRecordView.as_view(), name="charts-home"),
path("charts/spotify/", SpotifyTracksView.as_view(), name="spotify-tracks"),
path("charts/birds/", BirdsChartView.as_view(), name="birds-chart"),
]

View File

@ -415,6 +415,18 @@ class ChartRecordView(TemplateView):
),
}
bird_data = self.get_bird_chart_data(
user,
year=context.get("year"),
month=context.get("month"),
)
context["birds_chart"] = bird_data["top_birds"]
context["bird_stats"] = {
"total_species": bird_data["total_species"],
"total_outings": bird_data["total_outings"],
"total_individuals": bird_data["total_individuals"],
}
context["chart_years"] = self.get_available_years(user)
context["period_type"] = self.get_period_type()
context["prev_period"] = self.get_prev_period_url(user)
@ -558,6 +570,52 @@ class ChartRecordView(TemplateView):
return f"/charts/?date={year + 1}"
return None
def get_bird_chart_data(self, user, year=None, month=None, limit=20):
from birds.models import Bird
filters = {
"user": user,
"media_type": Scrobble.MediaType.BIRDING_LOCATION,
}
if year:
filters["timestamp__year"] = year
if month:
filters["timestamp__month"] = month
scrobbles = Scrobble.objects.filter(**filters)
bird_counts = {}
outings = 0
for scrobble in scrobbles.iterator():
outings += 1
birds_data = scrobble.log.get("birds", []) if scrobble.log else []
for entry in birds_data:
bird_id = entry.get("bird_id")
quantity = entry.get("quantity", 1)
if bird_id:
bird_counts[bird_id] = bird_counts.get(bird_id, 0) + quantity
sorted_birds = sorted(
bird_counts.items(), key=lambda x: x[1], reverse=True
)[:limit]
bird_ids = [bid for bid, _ in sorted_birds]
birds = Bird.objects.filter(id__in=bird_ids)
bird_map = {b.id: b for b in birds}
top_birds = [
{"bird": bird_map[bid], "count": count}
for bid, count in sorted_birds
if bid in bird_map
]
return {
"top_birds": top_birds,
"total_outings": outings,
"total_species": len(bird_counts),
"total_individuals": sum(bird_counts.values()),
}
class SpotifyTracksView(TemplateView):
template_name = "charts/spotify_tracks.html"
@ -594,3 +652,59 @@ class SpotifyTracksView(TemplateView):
user = self.request.user
context["spotify_tracks"] = self.get_spotify_tracks(user)
return context
class BirdsChartView(TemplateView):
template_name = "charts/birds_chart.html"
def get_bird_data(self, user, limit=50):
from birds.models import Bird
scrobbles = Scrobble.objects.filter(
user=user,
media_type=Scrobble.MediaType.BIRDING_LOCATION,
)
bird_counts = {}
outings = 0
for scrobble in scrobbles.iterator():
outings += 1
birds_data = scrobble.log.get("birds", []) if scrobble.log else []
for entry in birds_data:
bird_id = entry.get("bird_id")
quantity = entry.get("quantity", 1)
if bird_id:
bird_counts[bird_id] = bird_counts.get(bird_id, 0) + quantity
sorted_birds = sorted(
bird_counts.items(), key=lambda x: x[1], reverse=True
)[:limit]
bird_ids = [bid for bid, _ in sorted_birds]
birds = Bird.objects.filter(id__in=bird_ids)
bird_map = {b.id: b for b in birds}
top_birds = [
{"bird": bird_map[bid], "count": count}
for bid, count in sorted_birds
if bid in bird_map
]
return {
"top_birds": top_birds,
"total_outings": outings,
"total_species": len(bird_counts),
"total_individuals": sum(bird_counts.values()),
}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user = self.request.user
bird_data = self.get_bird_data(user)
context["birds_chart"] = bird_data["top_birds"]
context["bird_stats"] = {
"total_species": bird_data["total_species"],
"total_outings": bird_data["total_outings"],
"total_individuals": bird_data["total_individuals"],
}
return context

View File

@ -0,0 +1,63 @@
{% extends "base_list.html" %}
{% block title %}Top Birds{% endblock %}
{% block head_extra %}
<style>
.container { margin-bottom: 100px; }
</style>
{% endblock %}
{% block lists %}
<div class="row">
<div class="col-12">
<h2>🐦 Birding Charts</h2>
<p class="text-muted">All-time bird sightings based on scrobble log data.</p>
</div>
</div>
{% if bird_stats %}
<div class="row mb-3">
<div class="col-md-4 col-lg-3 mb-2">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">{{ bird_stats.total_species }}</h5>
<p class="card-text text-muted">Species seen</p>
</div>
</div>
</div>
<div class="col-md-4 col-lg-3 mb-2">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">{{ bird_stats.total_outings }}</h5>
<p class="card-text text-muted">Outings</p>
</div>
</div>
</div>
<div class="col-md-4 col-lg-3 mb-2">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">{{ bird_stats.total_individuals }}</h5>
<p class="card-text text-muted">Total individuals</p>
</div>
</div>
</div>
</div>
{% endif %}
<div class="row">
<div class="col-md-6 col-lg-4 chart-section">
<ul class="list-group">
{% for item in birds_chart %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{ forloop.counter }}</strong></span>
<a href="{{ item.bird.get_absolute_url }}">{{ item.bird.common_name }}</a>
<span class="badge bg-success rounded-pill">{{ item.count }}</span>
</li>
{% empty %}
<li class="list-group-item">No bird sightings found.</li>
{% endfor %}
</ul>
</div>
</div>
{% endblock %}

View File

@ -215,6 +215,26 @@
</ul>
</div>
{% endif %}
{% if birds_chart %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>🐦 Top Birds</h3>
<ul class="list-group">
{% for item in birds_chart %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{ forloop.counter }}</strong></span>
<a href="{{ item.bird.get_absolute_url }}">{{ item.bird.common_name }}</a>
<span class="badge bg-success rounded-pill">{{ item.count }}</span>
</li>
{% empty %}
<li class="list-group-item">No bird sightings this period.</li>
{% endfor %}
<li class="list-group-item">
<a href="{% url 'charts:birds-chart' %}">View all birds &raquo;</a>
</li>
</ul>
</div>
{% endif %}
</div>
{% if not charts %}