diff --git a/nummi/main/static/main/css/plot.css b/nummi/main/static/main/css/plot.css new file mode 100644 index 0000000..d43bfa2 --- /dev/null +++ b/nummi/main/static/main/css/plot.css @@ -0,0 +1,79 @@ +.plot table { + border-collapse: collapse; + width: 100%; +} +.plot tr { + padding-bottom: .5rem; +} +.plot th {text-align: center} +.plot th.r {text-align: right} +.plot th.l {text-align: left} + +.plot td, .plot th, .plot td.bar div { + position: relative; + height: 2rem; + line-height: 2rem; +} +.plot td, .plot th { + padding: .5rem var(--gap); +} + +.plot td:not(.bar), .plot tbody th { + width: 8rem; +} +.plot td.bar { + position: relative; + padding: 0; +} +.plot td.bar div { + position: absolute; + top: 0; +} +.plot td.m { + text-align: right; +} +.plot td.value { + padding: 0 var(--gap); + font-feature-settings: var(--num); + text-align: right; +} + +.plot td.bar div:not(.tot) { + width: 0; + box-sizing: border-box; + z-index: 1; + display: inline-block; +} +.plot td.bar.p div { + left: 0; + border-radius: 0 var(--radius) var(--radius) 0; +} +.plot td.bar.m div { + right: 0; + border-radius: var(--radius) 0 0 var(--radius); +} +.plot td.bar.m div:not(.tot) { + background: var(--red-1); +} +.plot td.bar.p div:not(.tot) { + background: var(--green-1); +} + +.plot td.bar div.tot { + z-index: 10; + height: .5rem; + background: black; +} +.plot td.bar div.tot span { + position: absolute; + display: inline-block; + white-space: nowrap; + margin: 0 var(--gap); + font-weight: 650; + top: .5rem; + line-height: 1.5rem; + height: 1.5rem; + font-feature-settings: var(--num); +} +.plot td.bar.p div.tot span {left: 0} +.plot td.bar.m div.tot span {right: 0} diff --git a/nummi/main/templates/main/category_form.html b/nummi/main/templates/main/category_form.html index 05c2254..c7046d4 100644 --- a/nummi/main/templates/main/category_form.html +++ b/nummi/main/templates/main/category_form.html @@ -21,8 +21,6 @@ {{ form }} {% if form.instance.transactions %} - Graph representing value over time

{% translate "Transactions" %}

{% include "main/table/transaction.html" %} {% endif %} diff --git a/nummi/main/templates/main/index.html b/nummi/main/templates/main/index.html index 503d0fe..96ca5bd 100644 --- a/nummi/main/templates/main/index.html +++ b/nummi/main/templates/main/index.html @@ -10,6 +10,9 @@ + {% endblock %} {% block body %} {% if accounts %} @@ -25,6 +28,8 @@ {% if transactions %}

{% translate "Transactions" %}

{% include "main/table/transaction.html" %} +

{% translate "History" %}

+ {% include "main/plot/history.html" %} {% endif %} {% if categories %}

{% translate "Categories" %}

diff --git a/nummi/main/templates/main/plot/history.html b/nummi/main/templates/main/plot/history.html new file mode 100644 index 0000000..2ca1511 --- /dev/null +++ b/nummi/main/templates/main/plot/history.html @@ -0,0 +1,40 @@ +{% load main_extras %} +{% load i18n %} +
+ + + + + + + + + + {% spaceless %} + {% for date in history.data %} + + + + + + + + {% endfor %} + {% endspaceless %} + +
{% translate "Month" %}{% translate "Expenses" %}{% translate "Income" %}
{{ date.month|date:"M y" }}{{ date.sum_m|pmrvalue }} +
+ {% if date.sum < 0 %} +
+ {{ date.sum|pmrvalue }} +
+ {% endif %} +
+
+ {% if date.sum > 0 %} +
+ {{ date.sum|pmrvalue }} +
+ {% endif %} +
{{ date.sum_p|pmrvalue }}
+
diff --git a/nummi/main/templates/main/snapshot_form.html b/nummi/main/templates/main/snapshot_form.html index bf5df46..8785ed8 100644 --- a/nummi/main/templates/main/snapshot_form.html +++ b/nummi/main/templates/main/snapshot_form.html @@ -40,7 +40,7 @@ {{ cat.category__name }} {% else %} - + {% endif %}
{{ cat.sum_m|pmvalue }}
diff --git a/nummi/main/templatetags/main_extras.py b/nummi/main/templatetags/main_extras.py index 3115d01..1e0dbd3 100644 --- a/nummi/main/templatetags/main_extras.py +++ b/nummi/main/templatetags/main_extras.py @@ -6,12 +6,12 @@ register = template.Library() @register.filter -def value(val, pm=False): +def value(val, pm=False, r=2): if not val: return mark_safe("–") _prefix = "" _suffix = " €" - _val = formats.number_format(val, 2, use_l10n=True, force_grouping=True) + _val = formats.number_format(val, r, use_l10n=True, force_grouping=True) if val > 0: if pm: @@ -28,6 +28,11 @@ def pmvalue(val): return value(val, True) +@register.filter +def pmrvalue(val): + return value(val, True, r=0) + + @register.inclusion_tag("main/tag/form_buttons.html") def form_buttons(instance): return { diff --git a/nummi/main/views.py b/nummi/main/views.py index 9b078d7..6103e5d 100644 --- a/nummi/main/views.py +++ b/nummi/main/views.py @@ -41,12 +41,34 @@ class IndexView(LoginRequiredMixin, TemplateView): _max = 8 _transactions = Transaction.objects.filter(user=self.request.user) _snapshots = Snapshot.objects.filter(user=self.request.user) + _history = ( + _transactions.filter(category__budget=True) + .values(month=models.functions.TruncMonth("date")) + .annotate( + sum_p=models.Sum("value", filter=models.Q(value__gt=0)), + sum_m=models.Sum("value", filter=models.Q(value__lt=0)), + sum=models.Sum("value"), + ) + .order_by("-month") + ) res = { "accounts": Account.objects.filter(user=self.request.user), "transactions": _transactions[:_max], "categories": Category.objects.filter(user=self.request.user), "snapshots": _snapshots[:_max], + "history": { + "data": _history, + "max": max( + map( + lambda x: abs(x) if x else 0, + _history.aggregate( + max=models.Max("sum_p"), + min=models.Min("sum_m"), + ).values(), + ) + ), + }, } if _transactions.count() > _max: res["transactions_url"] = reverse_lazy("transactions") @@ -233,9 +255,7 @@ class SnapshotUpdateView(NummiUpdateView): ) if _transactions: data["categories"] = ( - _transactions.values( - "category", "category__name", "category__icon" - ) + _transactions.values("category", "category__name", "category__icon") .annotate( sum=models.Sum("value"), sum_m=models.Sum("value", filter=models.Q(value__lt=0)), diff --git a/nummi/plot/__init__.py b/nummi/plot/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/nummi/plot/admin.py b/nummi/plot/admin.py deleted file mode 100644 index 846f6b4..0000000 --- a/nummi/plot/admin.py +++ /dev/null @@ -1 +0,0 @@ -# Register your models here. diff --git a/nummi/plot/apps.py b/nummi/plot/apps.py deleted file mode 100644 index 1f7a601..0000000 --- a/nummi/plot/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class PlotConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "plot" diff --git a/nummi/plot/migrations/__init__.py b/nummi/plot/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/nummi/plot/models.py b/nummi/plot/models.py deleted file mode 100644 index 6b20219..0000000 --- a/nummi/plot/models.py +++ /dev/null @@ -1 +0,0 @@ -# Create your models here. diff --git a/nummi/plot/nummi.mplstyle b/nummi/plot/nummi.mplstyle deleted file mode 100644 index b49262b..0000000 --- a/nummi/plot/nummi.mplstyle +++ /dev/null @@ -1,13 +0,0 @@ -font.family: Inter - -lines.linewidth: 2 - -figure.autolayout: True -figure.figsize: 8, 4 -figure.dpi: 300 - -axes.prop_cycle: cycler('color', ["66cc66", "338033", "99ff99", "802653", "cc6699"]) -axes.axisbelow: True -axes.grid: True - -svg.fonttype: none diff --git a/nummi/plot/tests.py b/nummi/plot/tests.py deleted file mode 100644 index a39b155..0000000 --- a/nummi/plot/tests.py +++ /dev/null @@ -1 +0,0 @@ -# Create your tests here. diff --git a/nummi/plot/urls.py b/nummi/plot/urls.py deleted file mode 100644 index 1412ba8..0000000 --- a/nummi/plot/urls.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.urls import path - -from . import views - -urlpatterns = [ - path("timeline", views.timeline, name="plot-timeline"), - path("categories", views.categories, name="plot-categories"), - path("category/", views.category, name="plot-category"), -] diff --git a/nummi/plot/views.py b/nummi/plot/views.py deleted file mode 100644 index 46ebfa5..0000000 --- a/nummi/plot/views.py +++ /dev/null @@ -1,108 +0,0 @@ -import io - -import matplotlib -import matplotlib.pyplot as plt -from django.contrib.auth.decorators import login_required -from django.db import models -from django.http import HttpResponse -from django.shortcuts import get_object_or_404 -from django.utils.translation import gettext as _ -from matplotlib import dates as mdates - -from main.models import Category, Snapshot, Transaction - -matplotlib.use("Agg") -plt.style.use("./plot/nummi.mplstyle") - - -@login_required -def timeline(request): - _snapshots = Snapshot.objects.all() - - fig, ax = plt.subplots() - ax.step( - [s.date for s in _snapshots], - [s.value for s in _snapshots], - where="post", - ) - ax.set(ylabel=_("Snapshots"), ylim=0) - ax.xaxis.set_major_formatter( - mdates.ConciseDateFormatter(ax.xaxis.get_major_locator()) - ) - ax.autoscale(True, "x", True) - - _io = io.StringIO() - - fig.savefig(_io, format="svg") - - return HttpResponse(_io.getvalue(), headers={"Content-Type": "image/svg+xml"}) - - -@login_required -def categories(request): - _categories = Category.objects.filter(budget=True) - - fig, ax = plt.subplots(figsize=(8, _categories.count() / 4)) - ax.barh( - [str(c) for c in _categories][::-1], - [ - Transaction.objects.filter(category=c).aggregate(sum=models.Sum("value"))[ - "sum" - ] - for c in _categories - ][::-1], - ) - - _io = io.StringIO() - - fig.savefig(_io, format="svg") - - return HttpResponse(_io.getvalue(), headers={"Content-Type": "image/svg+xml"}) - - -@login_required -def category(request, uuid): - _category = get_object_or_404(Category, id=uuid) - _values_p = ( - Transaction.objects.filter(category=_category) - .filter(value__gt=0) - .annotate(m=models.functions.TruncMonth("date")) - .values("m") - .annotate(sum=models.Sum("value")) - .order_by("m") - ) - _values_m = ( - Transaction.objects.filter(category=_category) - .filter(value__lt=0) - .annotate(m=models.functions.TruncMonth("date")) - .values("m") - .annotate(sum=models.Sum("value")) - .order_by("m") - ) - - fig, ax = plt.subplots() - ax.bar( - [v["m"] for v in _values_p], - [v["sum"] for v in _values_p], - width=12, - color="#66cc66", - ) - ax.bar( - [v["m"] for v in _values_m], - [v["sum"] for v in _values_m], - width=12, - color="#cc6699", - ) - ax.xaxis.set_major_formatter( - mdates.ConciseDateFormatter(ax.xaxis.get_major_locator()) - ) - ax.autoscale(True, "x", True) - _ym, _yp = ax.get_ylim() - ax.set(ylim=(min(_ym, 0), max(_yp, 0))) - ax.set(ylabel=f"{_category.name} (€)") - - _io = io.StringIO() - - fig.savefig(_io, format="svg") - - return HttpResponse(_io.getvalue(), headers={"Content-Type": "image/svg+xml"})