Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0b22b79dc | |||
| 6471413681 | |||
| 50b10689fc | |||
| 85bddb6cba | |||
| c285b0d3b3 | |||
| 671fe8d86f | |||
| 89817110de | |||
| ee01e3d8df |
83
PROJECT.org
83
PROJECT.org
@ -93,7 +93,7 @@ fetching and simple saving.
|
||||
:LOGBOOK:
|
||||
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
|
||||
:END:
|
||||
* Backlog [0/14] :vrobbler:project:personal:
|
||||
* Backlog [0/13] :vrobbler:project:personal:
|
||||
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
|
||||
:PROPERTIES:
|
||||
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
|
||||
@ -459,11 +459,11 @@ https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-li
|
||||
- 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] Find page numbers for comic books from ComicVine :vrobbler:feature:books:personal:project:
|
||||
** TODO [#B] Find page numbers for comic books from ComicVine :feature:books:
|
||||
:PROPERTIES:
|
||||
:ID: 79f867c3-1288-4143-b6bf-2a452983ee9f
|
||||
:END:
|
||||
** TODO [#B] Fix koreader scrobble imports to use DST properly :vrobbler:personal:bug:books:imports:
|
||||
** TODO [#B] Fix koreader scrobble imports to use DST properly :bug:books:imports:
|
||||
:PROPERTIES:
|
||||
:ID: 79758cba-a440-48b6-a637-efb88827acf2
|
||||
:END:
|
||||
@ -489,7 +489,36 @@ 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] Orgmode tasks are not updated if in progress :tasks:orgmode:bug:
|
||||
* Version 43.0 [5/5]
|
||||
** DONE [#B] Can we show a graph of all past Weigh-in tasks :scale:tasks:graphs:javascript:
|
||||
:PROPERTIES:
|
||||
:ID: ae499d87-03bf-4e48-9b2c-1a421a46af11
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
I wonder if, as a special type of task, Weigh-in's could show a graph of the
|
||||
metrics that are stored against all the past weigh-ins?
|
||||
|
||||
The graph would contain all Weigh-in scrobbles for that user, no matter which
|
||||
date is being viewed, and the highlighted value on the graph would be the date
|
||||
being viewed.
|
||||
|
||||
Probably could use something like chart.js although maybe that's too heavy?
|
||||
|
||||
And can we have each metric overlayed on the same graph?
|
||||
|
||||
** DONE [#B] When viewing scrobbles by tag, sum the total time :scrobbles:tags:
|
||||
:PROPERTIES:
|
||||
:ID: d51f23df-c2c5-4e1a-b000-67c89032af02
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
On scrobbles filtered by tags, we should see a sum of the time spent doing those tasks, in a human
|
||||
readable format like "X days, X hours, X minutes and X seconds"
|
||||
|
||||
** DONE [#A] Orgmode tasks are not updated if in progress :tasks:orgmode:bug:
|
||||
:PROPERTIES:
|
||||
:ID: 7dcebb2c-7c4c-4ac5-bee6-c2e36c3811f9
|
||||
:END:
|
||||
@ -505,6 +534,52 @@ different. And the same for comments. If a comment (by timestamp key) is
|
||||
different in the webhook than what's in the scrobble.log, update the comment in
|
||||
the scrobble.log
|
||||
|
||||
** DONE [#A] Ignore tag 'inprogress' for Tasks :bug:tasks:tags:
|
||||
:PROPERTIES:
|
||||
:ID: cd37c1ec-e2fc-b93c-daf8-6b329712c3f1
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
When scrobbling tasks from Todoist, the tag `inprogress` is always in the
|
||||
payload, because that's how we parse tasks starting from the Todoist webhooks.
|
||||
|
||||
But we don't really need anything tagged as `inprogress` Can we ignore this tag
|
||||
when applying tags to Task scrobbles coming from Todoist?`
|
||||
|
||||
|
||||
|
||||
** DONE [#A] Deploys are now throwing an unknown version error :bug:tooling:releases:
|
||||
:PROPERTIES:
|
||||
:ID: 3870f9d3-b5ed-4b87-9e8c-9bf905bfb766
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Almost everything is working, but for some reason `__version__` does not seem to
|
||||
exist.
|
||||
|
||||
#+begin_src sh
|
||||
out: Installing collected packages: vrobbler
|
||||
out: Successfully installed vrobbler-42.0
|
||||
err: WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
|
||||
err: Traceback (most recent call last):
|
||||
err: File "<string>", line 1, in <module>
|
||||
err: AttributeError: module 'vrobbler' has no attribute '__version__'
|
||||
2026/06/04 17:18:15 Process exited with status 1
|
||||
failed to remove container: Error response from daemon: removal of container c8ac64bee9b6bf5978d2c16f299e5ac271d8bbf7192b7a4023c3712bc2444f8b is already in progress
|
||||
❌ Failure - Main Install wheel and restart services
|
||||
exit with `FAILURE`: 1
|
||||
#+end_src
|
||||
|
||||
|
||||
* Version 42.0 [1/1]
|
||||
** DONE [#B] Add ability to add track to current Mopidy queue :feature:mopidy:tracks:
|
||||
:PROPERTIES:
|
||||
:ID: 79d5b580-4ea6-461b-4c6c-2c950d8b3e4c
|
||||
:END:
|
||||
|
||||
|
||||
* Version 41.0 [5/5]
|
||||
** DONE [#B] For any scrobble detail page with notes display them better :templates:notes:scrobbles:
|
||||
:PROPERTIES:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "41.0"
|
||||
version = "43.0"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
@ -107,6 +107,8 @@ exclude_dirs = ["*/tests/*", "*/migrations/*"]
|
||||
[tool.poetry.scripts]
|
||||
vrobbler = "vrobbler.cli:main"
|
||||
|
||||
[tool.poetry_bumpversion.file."vrobbler/__init__.py"]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
@ -2,4 +2,5 @@
|
||||
# Django starts so that shared_task will use this app.
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
__version__ = "42.0"
|
||||
__all__ = ("celery_app", "__version__")
|
||||
|
||||
@ -31,6 +31,7 @@ class UserProfileForm(forms.ModelForm):
|
||||
"webdav_auto_import",
|
||||
"ntfy_url",
|
||||
"ntfy_enabled",
|
||||
"mopidy_api_url",
|
||||
"redirect_to_webpage",
|
||||
"enable_public_widgets",
|
||||
"widget_custom_css",
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.30 on 2026-06-04 16:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0032_userprofile_weigh_in_units"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="mopidy_api_url",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -64,6 +64,8 @@ class UserProfile(TimeStampedModel):
|
||||
ntfy_url = models.CharField(max_length=255, **BNULL)
|
||||
ntfy_enabled = models.BooleanField(default=False)
|
||||
|
||||
mopidy_api_url = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
redirect_to_webpage = models.BooleanField(default=True)
|
||||
|
||||
enable_public_widgets = models.BooleanField(default=False)
|
||||
|
||||
@ -761,7 +761,10 @@ def todoist_scrobble_task(
|
||||
|
||||
todoist_task["title"] = todoist_task.pop("description")
|
||||
todoist_task["description"] = todoist_task.pop("details")
|
||||
todoist_task["labels"] = todoist_task.pop("todoist_label_list", [])
|
||||
labels = todoist_task.pop("todoist_label_list", [])
|
||||
todoist_task["labels"] = [
|
||||
l for l in labels if l.lower() != "inprogress"
|
||||
]
|
||||
todoist_task.pop("todoist_type")
|
||||
todoist_task.pop("todoist_event")
|
||||
|
||||
|
||||
@ -158,6 +158,11 @@ urlpatterns = [
|
||||
views.ScrobbleDetailView.as_view(),
|
||||
name="detail",
|
||||
),
|
||||
path(
|
||||
"scrobbles/<slug:uuid>/add-to-mopidy-queue/",
|
||||
views.add_to_mopidy_queue,
|
||||
name="add-to-mopidy-queue",
|
||||
),
|
||||
path("scrobbles/<slug:uuid>/start/", views.scrobble_start, name="start"),
|
||||
path("scrobbles/<slug:uuid>/finish/", views.scrobble_finish, name="finish"),
|
||||
path("scrobbles/<slug:uuid>/cancel/", views.scrobble_cancel, name="cancel"),
|
||||
|
||||
@ -4,6 +4,8 @@ import logging
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
|
||||
import pendulum
|
||||
import pytz
|
||||
from dateutil.relativedelta import relativedelta
|
||||
@ -12,7 +14,7 @@ from django.contrib import messages
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
||||
from django.db.models import Count, Max, Q
|
||||
from django.db.models import Count, Max, Q, Sum
|
||||
from django.db.models.query import QuerySet
|
||||
from rest_framework.authentication import TokenAuthentication
|
||||
from rest_framework.authtoken.models import Token
|
||||
@ -30,10 +32,11 @@ from django.http import (
|
||||
HttpResponseRedirect,
|
||||
JsonResponse,
|
||||
)
|
||||
from django.shortcuts import redirect
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.generic import DetailView, FormView, TemplateView
|
||||
from django.views.generic.edit import CreateView
|
||||
from django.views.generic.list import ListView
|
||||
@ -364,6 +367,7 @@ class ScrobbleListView(LoginRequiredMixin, ListView):
|
||||
else:
|
||||
tag_list = []
|
||||
self.tag_list = tag_list
|
||||
self._full_queryset = qs
|
||||
return qs
|
||||
|
||||
def _compute_overlap_groups(self, scrobbles):
|
||||
@ -430,6 +434,13 @@ class ScrobbleListView(LoginRequiredMixin, ListView):
|
||||
ctx["tag_list"] = getattr(self, "tag_list", [])
|
||||
scrobbles = list(ctx.get("object_list", []))
|
||||
ctx["overlap_map"] = self._compute_overlap_groups(scrobbles)
|
||||
full_qs = getattr(self, "_full_queryset", None)
|
||||
if full_qs is not None and getattr(self, "tag_list", []):
|
||||
total = (
|
||||
full_qs.aggregate(total=Sum("playback_position_seconds"))["total"]
|
||||
or 0
|
||||
)
|
||||
ctx["total_time_seconds"] = total
|
||||
return ctx
|
||||
|
||||
|
||||
@ -963,6 +974,65 @@ def scrobble_cancel(request, uuid):
|
||||
return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
|
||||
|
||||
|
||||
@require_POST
|
||||
def add_to_mopidy_queue(request, uuid):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect("scrobbles:detail", uuid=uuid)
|
||||
|
||||
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
|
||||
mopidy_url = request.user.profile.mopidy_api_url
|
||||
|
||||
if not mopidy_url:
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.ERROR,
|
||||
"Mopidy API URL not configured in your profile settings.",
|
||||
)
|
||||
return redirect("scrobbles:detail", uuid=uuid)
|
||||
|
||||
raw_data = scrobble.log.get("raw_data", {})
|
||||
mopidy_uri = raw_data.get("mopidy_uri")
|
||||
logger.debug(mopidy_uri)
|
||||
|
||||
if not mopidy_uri:
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.ERROR,
|
||||
"No Mopidy URI found for this scrobble.",
|
||||
)
|
||||
return redirect("scrobbles:detail", uuid=uuid)
|
||||
|
||||
rpc_url = mopidy_url.rstrip("/") + "/rpc"
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "core.tracklist.add",
|
||||
"params": {"uris": [mopidy_uri]},
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(rpc_url, json=payload, timeout=10)
|
||||
resp.raise_for_status()
|
||||
rpc_result = resp.json()
|
||||
if rpc_result.get("error"):
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.ERROR,
|
||||
f'Mopidy error: {rpc_result["error"]}',
|
||||
)
|
||||
else:
|
||||
msg = f'Added "{scrobble.media_obj}" to Mopidy queue.'
|
||||
messages.add_message(request, messages.SUCCESS, msg)
|
||||
except requests.RequestException as e:
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.ERROR,
|
||||
f"Failed to contact Mopidy: {e}",
|
||||
)
|
||||
|
||||
return redirect("scrobbles:detail", uuid=uuid)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def export(request):
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import pendulum
|
||||
from django.contrib import messages
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse_lazy
|
||||
@ -8,6 +9,7 @@ from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
|
||||
from tasks.models import Task
|
||||
|
||||
@ -23,6 +25,72 @@ class TaskListView(ScrobbleableListView):
|
||||
class TaskDetailView(ScrobbleableDetailView):
|
||||
model = Task
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
if self.object.title != "Weigh-in":
|
||||
return ctx
|
||||
|
||||
scrobbles = list(
|
||||
Scrobble.objects.filter(
|
||||
user=self.request.user,
|
||||
task=self.object,
|
||||
log__weight__isnull=False,
|
||||
).order_by("timestamp")
|
||||
)
|
||||
if not scrobbles:
|
||||
return ctx
|
||||
|
||||
labels = []
|
||||
weight_data = []
|
||||
body_fat_data = []
|
||||
bmi_data = []
|
||||
for s in scrobbles:
|
||||
ts = s.timestamp
|
||||
if isinstance(ts, str):
|
||||
ts = pendulum.parse(ts)
|
||||
labels.append(ts.strftime("%Y-%m-%d"))
|
||||
log = s.log if isinstance(s.log, dict) else {}
|
||||
raw_weight = log.get("weight")
|
||||
weight_data.append(
|
||||
float(raw_weight) if raw_weight is not None else None
|
||||
)
|
||||
raw_bf = log.get("body_fat")
|
||||
body_fat_data.append(
|
||||
float(raw_bf) if raw_bf is not None else None
|
||||
)
|
||||
raw_bmi = log.get("bmi")
|
||||
bmi_data.append(
|
||||
float(raw_bmi) if raw_bmi is not None else None
|
||||
)
|
||||
|
||||
ctx["weighin_chart"] = {
|
||||
"labels": labels,
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Weight",
|
||||
"data": weight_data,
|
||||
"borderColor": "#4bc0c0",
|
||||
"fill": False,
|
||||
"yAxisID": "y",
|
||||
},
|
||||
{
|
||||
"label": "Body Fat %",
|
||||
"data": body_fat_data,
|
||||
"borderColor": "#ff6384",
|
||||
"fill": False,
|
||||
"yAxisID": "y1",
|
||||
},
|
||||
{
|
||||
"label": "BMI",
|
||||
"data": bmi_data,
|
||||
"borderColor": "#36a2eb",
|
||||
"fill": False,
|
||||
"yAxisID": "y2",
|
||||
},
|
||||
],
|
||||
}
|
||||
return ctx
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
|
||||
@ -232,7 +232,7 @@ class EmacsWebhookView(APIView):
|
||||
status=status.HTTP_304_NOT_MODIFIED,
|
||||
)
|
||||
|
||||
if task_in_progress:
|
||||
if scrobble and scrobble.in_progress:
|
||||
emacs_scrobble_update_task(
|
||||
post_data.get("source_id"),
|
||||
post_data.get("notes") or [],
|
||||
|
||||
@ -8,21 +8,20 @@ def natural_duration(value):
|
||||
if not value:
|
||||
return
|
||||
value = int(value)
|
||||
total_minutes = int(value / 60)
|
||||
hours = int(total_minutes / 60)
|
||||
minutes = total_minutes - (hours * 60)
|
||||
seconds = value % 60
|
||||
value_str = ""
|
||||
if seconds:
|
||||
value_str = f"{seconds} seconds"
|
||||
if minutes:
|
||||
if value_str:
|
||||
value_str = f"{minutes} minutes, " + value_str
|
||||
else:
|
||||
value_str = f"{minutes} minutes"
|
||||
days = int(value / 86400)
|
||||
remainder = value % 86400
|
||||
hours = int(remainder / 3600)
|
||||
minutes = int((remainder % 3600) / 60)
|
||||
seconds = remainder % 60
|
||||
parts = []
|
||||
if days:
|
||||
parts.append(f"{days} day{'s' if days != 1 else ''}")
|
||||
if hours:
|
||||
if value_str:
|
||||
value_str = f"{hours} hours, " + value_str
|
||||
else:
|
||||
value_str = f"{hours} hours"
|
||||
return value_str
|
||||
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
|
||||
if minutes:
|
||||
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
|
||||
if seconds or not parts:
|
||||
parts.append(f"{seconds} second{'s' if seconds != 1 else ''}")
|
||||
if len(parts) == 1:
|
||||
return parts[0]
|
||||
return ", ".join(parts[:-1]) + " and " + parts[-1]
|
||||
|
||||
@ -9,6 +9,9 @@
|
||||
<h1 class="h2">All Scrobbles</h1>
|
||||
{% if tag_list %}
|
||||
<h6 class="text-muted">Tagged {{ tag_list|join:", " }}</h6>
|
||||
{% if total_time_seconds %}
|
||||
<p class="text-muted small mb-0">Total time: {{ total_time_seconds|natural_duration }}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -35,11 +35,18 @@
|
||||
|
||||
<h1>
|
||||
{% if object.media_type == "Video" %}🎬{% elif object.media_type == "Track" %}🎵{% elif object.media_type == "PodcastEpisode" %}🎙️{% elif object.media_type == "SportEvent" %}⚽{% elif object.media_type == "Book" %}📚{% elif object.media_type == "Paper" %}📄{% elif object.media_type == "VideoGame" %}🎮{% elif object.media_type == "BoardGame" %}🎲{% elif object.media_type == "GeoLocation" %}📍{% elif object.media_type == "Trail" %}🥾{% elif object.media_type == "Beer" %}🍺{% elif object.media_type == "Puzzle" %}🧩{% elif object.media_type == "Food" %}🍔{% elif object.media_type == "Task" %}✅{% elif object.media_type == "WebPage" %}🌐{% elif object.media_type == "LifeEvent" %}🎉{% elif object.media_type == "Mood" %}😊{% elif object.media_type == "BrickSet" %}🧱{% elif object.media_type == "Channel" %}📺{% endif %}
|
||||
{% if object.media_obj.get_absolute_url %}<a href="{{ object.media_obj.get_absolute_url }}">{% endif %}{{ object.media_obj }}{% if object.media_obj.get_absolute_url %}</a>{% endif %}</h1>
|
||||
{% if object.media_obj.get_absolute_url %}<a href="{{ object.media_obj.get_absolute_url }}">{% endif %}{{ object.media_obj }}{% if object.media_obj.get_absolute_url %}</a>{% endif %}
|
||||
</h1>
|
||||
{% if object.media_type == "Task" and object.logdata.title %}
|
||||
<h2>{{ object.logdata.title }}</h2>
|
||||
{% endif %}
|
||||
<h3 class="text-muted">{{ object.local_timestamp }}</h3>
|
||||
{% if object.media_type == "Track" and object.log.raw_data.mopidy_uri and user.profile.mopidy_api_url %}
|
||||
<form method="post" action="{% url 'scrobbles:add-to-mopidy-queue' object.uuid %}" class="mb-1">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">add to mopidy queue</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if object.media_type == "Track" %}
|
||||
<p class="text-muted small">Source: {{ object.source }}{% if object.log.mopidy_source %} ({{ object.log.mopidy_source|capfirst }}){% endif %}</p>
|
||||
{% endif %}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
{% load mathfilters %}
|
||||
{% load static %}
|
||||
{% load naturalduration %}
|
||||
{% load l10n %}
|
||||
|
||||
{% block title %}{{object.title}}{% endblock %}
|
||||
|
||||
@ -39,6 +40,15 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if weighin_chart %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<canvas id="weighinChart" width="800" height="300"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<p>{{scrobbles.count}} scrobbles</p>
|
||||
<p>
|
||||
@ -94,3 +104,61 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% if weighin_chart %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js"></script>
|
||||
<script>
|
||||
var ctx = document.getElementById('weighinChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: {{ weighin_chart.labels|safe }},
|
||||
datasets: [
|
||||
{
|
||||
label: 'Weight',
|
||||
data: {{ weighin_chart.datasets.0.data|safe }},
|
||||
borderColor: '{{ weighin_chart.datasets.0.borderColor }}',
|
||||
backgroundColor: 'transparent',
|
||||
pointRadius: 2,
|
||||
fill: false,
|
||||
yAxisID: 'y',
|
||||
},
|
||||
{
|
||||
label: 'Body Fat %',
|
||||
data: {{ weighin_chart.datasets.1.data|safe }},
|
||||
borderColor: '{{ weighin_chart.datasets.1.borderColor }}',
|
||||
backgroundColor: 'transparent',
|
||||
pointRadius: 2,
|
||||
fill: false,
|
||||
yAxisID: 'y1',
|
||||
},
|
||||
{
|
||||
label: 'BMI',
|
||||
data: {{ weighin_chart.datasets.2.data|safe }},
|
||||
borderColor: '{{ weighin_chart.datasets.2.borderColor }}',
|
||||
backgroundColor: 'transparent',
|
||||
pointRadius: 2,
|
||||
fill: false,
|
||||
yAxisID: 'y2',
|
||||
},
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
xAxes: [{
|
||||
ticks: { maxTicksLimit: 25, maxRotation: 45 },
|
||||
}],
|
||||
yAxes: [
|
||||
{ id: 'y', type: 'linear', position: 'left', scaleLabel: { display: true, labelString: 'Weight' } },
|
||||
{ id: 'y1', type: 'linear', position: 'right', scaleLabel: { display: true, labelString: 'Body Fat %' }, gridLines: { display: false } },
|
||||
{ id: 'y2', type: 'linear', position: 'right', scaleLabel: { display: true, labelString: 'BMI' }, gridLines: { display: false } },
|
||||
]
|
||||
},
|
||||
legend: { position: 'bottom' },
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user