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 %}
-
{% 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 %}
+
+
+
+
+ {% translate "Month" %} |
+ {% translate "Expenses" %} |
+ {% translate "Income" %} |
+
+
+
+ {% spaceless %}
+ {% for date in history.data %}
+
+ {{ 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 }} |
+
+ {% endfor %}
+ {% endspaceless %}
+
+
+
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"})