Merge pull request #478 from eitchtee/dev

feat(insights): new month by month insight
This commit is contained in:
Herculino Trotta
2025-12-28 22:58:56 -03:00
committed by GitHub
5 changed files with 623 additions and 0 deletions

View File

@@ -54,4 +54,9 @@ urlpatterns = [
views.year_by_year,
name="insights_year_by_year",
),
path(
"insights/month-by-month/",
views.month_by_month,
name="insights_month_by_month",
),
]

View File

@@ -0,0 +1,316 @@
from collections import OrderedDict
from decimal import Decimal
from django.db import models
from django.db.models import Sum, Case, When, Value
from django.db.models.functions import Coalesce
from django.utils import timezone
from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
from apps.transactions.models import Transaction
def get_month_by_month_data(year=None, group_by="categories"):
"""
Aggregate transaction totals by month for a specific year, grouped by categories, tags, or entities.
Args:
year: The year to filter transactions (defaults to current year)
group_by: One of "categories", "tags", or "entities"
Returns:
{
"year": 2025,
"available_years": [2025, 2024, ...],
"months": [1, 2, 3, ..., 12],
"items": {
item_id: {
"name": "Item Name",
"month_totals": {
1: {"currencies": {...}},
...
},
"total": {"currencies": {...}}
},
...
},
"month_totals": {...},
"grand_total": {"currencies": {...}}
}
"""
if year is None:
year = timezone.localdate(timezone.now()).year
# Base queryset - all paid transactions, non-muted
transactions = Transaction.objects.filter(
is_paid=True,
account__is_archived=False,
).exclude(account__currency__is_archived=True)
# Get available years for the selector
available_years = list(
transactions.values_list("reference_date__year", flat=True)
.distinct()
.order_by("-reference_date__year")
)
# Filter by the selected year
transactions = transactions.filter(reference_date__year=year)
# Define grouping fields based on group_by parameter
if group_by == "tags":
group_field = "tags"
name_field = "tags__name"
elif group_by == "entities":
group_field = "entities"
name_field = "entities__name"
else: # Default to categories
group_field = "category"
name_field = "category__name"
# Months 1-12
months = list(range(1, 13))
if not available_years:
return {
"year": year,
"available_years": [],
"months": months,
"items": {},
"month_totals": {},
"grand_total": {"currencies": {}},
}
# Aggregate by group, month, and currency
metrics = (
transactions.values(
group_field,
name_field,
"reference_date__month",
"account__currency",
"account__currency__code",
"account__currency__name",
"account__currency__decimal_places",
"account__currency__prefix",
"account__currency__suffix",
"account__currency__exchange_currency",
)
.annotate(
expense_total=Coalesce(
Sum(
Case(
When(type=Transaction.Type.EXPENSE, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
income_total=Coalesce(
Sum(
Case(
When(type=Transaction.Type.INCOME, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
)
.order_by(name_field, "reference_date__month")
)
# Build result structure
result = {
"year": year,
"available_years": available_years,
"months": months,
"items": OrderedDict(),
"month_totals": {},
"grand_total": {"currencies": {}},
}
# Store currency info for later use in totals
currency_info = {}
for metric in metrics:
item_id = metric[group_field]
item_name = metric[name_field]
month = metric["reference_date__month"]
currency_id = metric["account__currency"]
# Use a consistent key for None (uncategorized/untagged/no entity)
item_key = item_id if item_id is not None else "__none__"
if item_key not in result["items"]:
result["items"][item_key] = {
"name": item_name,
"month_totals": {},
"total": {"currencies": {}},
}
if month not in result["items"][item_key]["month_totals"]:
result["items"][item_key]["month_totals"][month] = {"currencies": {}}
# Calculate final total (income - expense)
final_total = metric["income_total"] - metric["expense_total"]
# Store currency info for totals calculation
if currency_id not in currency_info:
currency_info[currency_id] = {
"code": metric["account__currency__code"],
"name": metric["account__currency__name"],
"decimal_places": metric["account__currency__decimal_places"],
"prefix": metric["account__currency__prefix"],
"suffix": metric["account__currency__suffix"],
"exchange_currency_id": metric["account__currency__exchange_currency"],
}
currency_data = {
"currency": {
"code": metric["account__currency__code"],
"name": metric["account__currency__name"],
"decimal_places": metric["account__currency__decimal_places"],
"prefix": metric["account__currency__prefix"],
"suffix": metric["account__currency__suffix"],
},
"final_total": final_total,
"income_total": metric["income_total"],
"expense_total": metric["expense_total"],
}
# Handle currency conversion if exchange currency is set
if metric["account__currency__exchange_currency"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=metric["account__currency__exchange_currency"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=final_total,
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
currency_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
result["items"][item_key]["month_totals"][month]["currencies"][currency_id] = (
currency_data
)
# Accumulate item total (across all months for this item)
if currency_id not in result["items"][item_key]["total"]["currencies"]:
result["items"][item_key]["total"]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["items"][item_key]["total"]["currencies"][currency_id][
"final_total"
] += final_total
# Accumulate month total (across all items for this month)
if month not in result["month_totals"]:
result["month_totals"][month] = {"currencies": {}}
if currency_id not in result["month_totals"][month]["currencies"]:
result["month_totals"][month]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["month_totals"][month]["currencies"][currency_id]["final_total"] += (
final_total
)
# Accumulate grand total
if currency_id not in result["grand_total"]["currencies"]:
result["grand_total"]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["grand_total"]["currencies"][currency_id]["final_total"] += final_total
# Add currency conversion for item totals
for item_key, item_data in result["items"].items():
for currency_id, total_data in item_data["total"]["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
# Add currency conversion for month totals
for month, month_data in result["month_totals"].items():
for currency_id, total_data in month_data["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
# Add currency conversion for grand total
for currency_id, total_data in result["grand_total"]["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
return result

View File

@@ -26,6 +26,8 @@ from apps.insights.utils.sankey import (
generate_sankey_data_by_currency,
)
from apps.insights.utils.transactions import get_transactions
from apps.insights.utils.year_by_year import get_year_by_year_data
from apps.insights.utils.month_by_month import get_month_by_month_data
from apps.transactions.models import TransactionCategory, Transaction
from apps.transactions.utils.calculations import calculate_currency_totals
@@ -333,3 +335,44 @@ def year_by_year(request):
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def month_by_month(request):
# Handle year selection
if "year" in request.GET:
try:
year = int(request.GET["year"])
request.session["insights_month_by_month_year"] = year
except (ValueError, TypeError):
year = request.session.get(
"insights_month_by_month_year", timezone.localdate(timezone.now()).year
)
else:
year = request.session.get(
"insights_month_by_month_year", timezone.localdate(timezone.now()).year
)
# Handle group_by selection
if "group_by" in request.GET:
group_by = request.GET["group_by"]
request.session["insights_month_by_month_group_by"] = group_by
else:
group_by = request.session.get("insights_month_by_month_group_by", "categories")
# Validate group_by value
if group_by not in ("categories", "tags", "entities"):
group_by = "categories"
data = get_month_by_month_data(year=year, group_by=group_by)
return render(
request,
"insights/fragments/month_by_month.html",
{
"data": data,
"group_by": group_by,
"selected_year": year,
},
)

View File

@@ -0,0 +1,254 @@
{% load i18n %}
<div hx-get="{% url 'insights_month_by_month' %}" hx-trigger="updated from:window" class="show-loading"
hx-swap="outerHTML" hx-include="#year-selector, #group-by-selector-month">
{# Hidden input to hold the year value #}
<input type="hidden" name="year" id="year-selector" value="{{ selected_year }}" _="on change trigger updated">
{# Tabs for Categories/Tags/Entities #}
<div class="h-full text-center mb-4">
<div class="tabs tabs-box mx-auto w-fit" role="group" id="group-by-selector-month" _="on change trigger updated">
<label class="tab">
<input type="radio"
name="group_by"
id="categories-view-month"
autocomplete="off"
value="categories"
aria-label="{% trans 'Categories' %}"
{% if group_by == "categories" %}checked{% endif %}>
<i class="fa-solid fa-icons fa-fw me-2"></i>
{% trans 'Categories' %}
</label>
<label class="tab">
<input type="radio"
name="group_by"
id="tags-view-month"
autocomplete="off"
value="tags"
aria-label="{% trans 'Tags' %}"
{% if group_by == "tags" %}checked{% endif %}>
<i class="fa-solid fa-hashtag fa-fw me-2"></i>
{% trans 'Tags' %}
</label>
<label class="tab">
<input type="radio"
name="group_by"
id="entities-view-month"
autocomplete="off"
value="entities"
aria-label="{% trans 'Entities' %}"
{% if group_by == "entities" %}checked{% endif %}>
<i class="fa-solid fa-user-group fa-fw me-2"></i>
{% trans 'Entities' %}
</label>
</div>
</div>
{% if data.items %}
<div class="card bg-base-100 card-border">
<div class="card-body">
{# Year dropdown - left aligned #}
{% if data.available_years %}
<div class="mb-4">
<div>
<button class="btn btn-ghost" type="button"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-calendar fa-fw me-1"></i>
{{ selected_year }}
<i class="fa-solid fa-chevron-down fa-fw ms-1"></i>
</button>
<ul class="dropdown-menu menu">
{% for year in data.available_years %}
<li>
<button class="{% if year == selected_year %}menu-active{% endif %}" type="button"
_="on click remove .menu-active from <li > button/> in the closest <ul/>
then add .menu-active to me
then set the value of #year-selector to '{{ year }}'
then trigger change on #year-selector">
{{ year }}
</button>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th scope="col" class="sticky left-0 bg-base-100 z-10">
{% if group_by == "categories" %}
{% trans 'Category' %}
{% elif group_by == "tags" %}
{% trans 'Tag' %}
{% else %}
{% trans 'Entity' %}
{% endif %}
</th>
<th scope="col" class="text-center font-bold">{% trans 'Total' %}</th>
{% for month in data.months %}
<th scope="col" class="text-center">
{% if month == 1 %}{% trans 'Jan' %}
{% elif month == 2 %}{% trans 'Feb' %}
{% elif month == 3 %}{% trans 'Mar' %}
{% elif month == 4 %}{% trans 'Apr' %}
{% elif month == 5 %}{% trans 'May' %}
{% elif month == 6 %}{% trans 'Jun' %}
{% elif month == 7 %}{% trans 'Jul' %}
{% elif month == 8 %}{% trans 'Aug' %}
{% elif month == 9 %}{% trans 'Sep' %}
{% elif month == 10 %}{% trans 'Oct' %}
{% elif month == 11 %}{% trans 'Nov' %}
{% elif month == 12 %}{% trans 'Dec' %}
{% endif %}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for item_id, item in data.items.items %}
<tr>
<th class="text-nowrap sticky left-0 bg-base-100 z-10">
{% if item.name %}
{{ item.name }}
{% else %}
{% if group_by == "categories" %}
{% trans 'Uncategorized' %}
{% elif group_by == "tags" %}
{% trans 'Untagged' %}
{% else %}
{% trans 'No entity' %}
{% endif %}
{% endif %}
</th>
{# Total column for this item #}
<td class="text-nowrap text-center font-semibold bg-base-200">
{% for currency_id, currency_data in item.total.currencies.items %}
<c-amount.display
:amount="currency_data.final_total"
:prefix="currency_data.currency.prefix"
:suffix="currency_data.currency.suffix"
:decimal_places="currency_data.currency.decimal_places"
color="{% if currency_data.final_total < 0 %}red{% elif currency_data.final_total > 0 %}green{% endif %}"></c-amount.display>
{% if currency_data.exchanged %}
<div class="text-xs text-base-content/60">
<c-amount.display
:amount="currency_data.exchanged.final_total"
:prefix="currency_data.exchanged.currency.prefix"
:suffix="currency_data.exchanged.currency.suffix"
:decimal_places="currency_data.exchanged.currency.decimal_places"
color="{% if currency_data.exchanged.final_total < 0 %}red{% elif currency_data.exchanged.final_total > 0 %}green{% endif %}"></c-amount.display>
</div>
{% endif %}
{% empty %}
-
{% endfor %}
</td>
{# Month columns #}
{% for month in data.months %}
<td class="text-nowrap text-center">
{% with month_data=item.month_totals %}
{% for m, m_data in month_data.items %}
{% if m == month %}
{% for currency_id, currency_data in m_data.currencies.items %}
<c-amount.display
:amount="currency_data.final_total"
:prefix="currency_data.currency.prefix"
:suffix="currency_data.currency.suffix"
:decimal_places="currency_data.currency.decimal_places"
color="{% if currency_data.final_total < 0 %}red{% elif currency_data.final_total > 0 %}green{% endif %}"></c-amount.display>
{% if currency_data.exchanged %}
<div class="text-xs text-base-content/60">
<c-amount.display
:amount="currency_data.exchanged.final_total"
:prefix="currency_data.exchanged.currency.prefix"
:suffix="currency_data.exchanged.currency.suffix"
:decimal_places="currency_data.exchanged.currency.decimal_places"
color="{% if currency_data.exchanged.final_total < 0 %}red{% elif currency_data.exchanged.final_total > 0 %}green{% endif %}"></c-amount.display>
</div>
{% endif %}
{% empty %}
-
{% endfor %}
{% endif %}
{% empty %}
-
{% endfor %}
{% endwith %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="font-bold bg-base-200">
<th class="sticky left-0 bg-base-200 z-10">{% trans 'Total' %}</th>
{# Grand total #}
<td class="text-nowrap text-center bg-base-300">
{% for currency_id, currency_data in data.grand_total.currencies.items %}
<c-amount.display
:amount="currency_data.final_total"
:prefix="currency_data.currency.prefix"
:suffix="currency_data.currency.suffix"
:decimal_places="currency_data.currency.decimal_places"
color="{% if currency_data.final_total < 0 %}red{% elif currency_data.final_total > 0 %}green{% endif %}"></c-amount.display>
{% if currency_data.exchanged %}
<div class="text-xs text-base-content/60">
<c-amount.display
:amount="currency_data.exchanged.final_total"
:prefix="currency_data.exchanged.currency.prefix"
:suffix="currency_data.exchanged.currency.suffix"
:decimal_places="currency_data.exchanged.currency.decimal_places"
color="{% if currency_data.exchanged.final_total < 0 %}red{% elif currency_data.exchanged.final_total > 0 %}green{% endif %}"></c-amount.display>
</div>
{% endif %}
{% empty %}
-
{% endfor %}
</td>
{# Month totals #}
{% for month in data.months %}
<td class="text-nowrap text-center">
{% with month_total=data.month_totals %}
{% for m, m_data in month_total.items %}
{% if m == month %}
{% for currency_id, currency_data in m_data.currencies.items %}
<c-amount.display
:amount="currency_data.final_total"
:prefix="currency_data.currency.prefix"
:suffix="currency_data.currency.suffix"
:decimal_places="currency_data.currency.decimal_places"
color="{% if currency_data.final_total < 0 %}red{% elif currency_data.final_total > 0 %}green{% endif %}"></c-amount.display>
{% if currency_data.exchanged %}
<div class="text-xs text-base-content/60">
<c-amount.display
:amount="currency_data.exchanged.final_total"
:prefix="currency_data.exchanged.currency.prefix"
:suffix="currency_data.exchanged.currency.suffix"
:decimal_places="currency_data.exchanged.currency.decimal_places"
color="{% if currency_data.exchanged.final_total < 0 %}red{% elif currency_data.exchanged.final_total > 0 %}green{% endif %}"></c-amount.display>
</div>
{% endif %}
{% empty %}
-
{% endfor %}
{% endif %}
{% empty %}
-
{% endfor %}
{% endwith %}
</td>
{% endfor %}
</tr>
</tfoot>
</table>
</div>
</div>
</div>
{% else %}
<c-msg.empty title="{% translate 'No transactions for this year' %}"></c-msg.empty>
{% endif %}
</div>

View File

@@ -126,6 +126,11 @@
hx-get="{% url 'insights_year_by_year' %}">
{% trans 'Year by Year' %}
</button>
<button class="btn btn-ghost btn-free justify-start text-start" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'insights_month_by_month' %}">
{% trans 'Month by Month' %}
</button>
</div>
</div>
</div>