Skip to content

Commit 7cc3514

Browse files
committed
dashboard analytics v2
1 parent d2534fb commit 7cc3514

10 files changed

Lines changed: 639 additions & 219 deletions

File tree

api/routes/stats_route.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
from __future__ import annotations
44

5-
from typing import Annotated, Any
5+
import datetime as dt
6+
from typing import Annotated, Any, Literal
67

7-
from fastapi import APIRouter, Depends, Query
8+
from fastapi import APIRouter, Depends, HTTPException, Query
89
from sqlalchemy.orm import Session
910

1011
from core.database import get_db
@@ -15,6 +16,19 @@
1516
router = APIRouter(prefix="/stats", tags=["stats"])
1617

1718

19+
def _parse_iso(value: str | None) -> dt.datetime | None:
20+
if value is None:
21+
return None
22+
try:
23+
# ``fromisoformat`` accepts both date and datetime strings (Py 3.11+).
24+
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
25+
except ValueError as exc:
26+
raise HTTPException(status_code=400, detail=f"Invalid ISO date: {value}") from exc
27+
if parsed.tzinfo is None:
28+
parsed = parsed.replace(tzinfo=dt.UTC)
29+
return parsed
30+
31+
1832
@router.get("/dashboard")
1933
def dashboard(
2034
db: Annotated[Session, Depends(get_db)],
@@ -23,5 +37,18 @@ def dashboard(
2337
False,
2438
description="If true, sums Cardmarket EUR per article via PokéWallet (slower).",
2539
),
40+
start: str | None = Query(None, description="Range start (ISO date or datetime)."),
41+
end: str | None = Query(None, description="Range end (ISO date or datetime)."),
42+
period: Literal["daily", "weekly", "monthly"] = Query(
43+
"daily",
44+
description="Bucket size for the revenue timeline.",
45+
),
2646
) -> dict[str, Any]:
27-
return compute_dashboard_stats(db, user.id, include_market=include_market)
47+
return compute_dashboard_stats(
48+
db,
49+
user.id,
50+
include_market=include_market,
51+
range_start=_parse_iso(start),
52+
range_end=_parse_iso(end),
53+
period=period,
54+
)

api/services/stats_service.py

Lines changed: 145 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@
33
from __future__ import annotations
44

55
import datetime as dt
6-
from typing import Any
6+
from typing import Any, Literal
77

88
from sqlalchemy.orm import Session
99

1010
from models.article import Article
1111
from services import article_service
1212
from services.pricing_service import fetch_card_prices
1313

14+
Period = Literal["daily", "weekly", "monthly"]
15+
1416

1517
def _as_utc(value: dt.datetime | None) -> dt.datetime | None:
1618
if value is None:
@@ -47,36 +49,125 @@ def _hours_to_sell(article: Article) -> float | None:
4749
return max(0.0, delta.total_seconds() / 3600.0)
4850

4951

52+
def _bucket_key(value: dt.datetime, period: Period) -> dt.datetime:
53+
"""Truncate a UTC datetime to the start of its daily/weekly/monthly bucket."""
54+
if period == "monthly":
55+
return value.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
56+
base = value.replace(hour=0, minute=0, second=0, microsecond=0)
57+
if period == "weekly":
58+
# Monday as week start
59+
return base - dt.timedelta(days=base.weekday())
60+
return base
61+
62+
63+
def _iter_buckets(start: dt.datetime, end: dt.datetime, period: Period) -> list[dt.datetime]:
64+
"""Inclusive list of bucket starts covering [start, end]."""
65+
if start > end:
66+
start, end = end, start
67+
cursor = _bucket_key(start, period)
68+
last = _bucket_key(end, period)
69+
buckets: list[dt.datetime] = []
70+
safety = 0
71+
while cursor <= last and safety < 5000:
72+
buckets.append(cursor)
73+
if period == "monthly":
74+
year, month = cursor.year, cursor.month + 1
75+
if month > 12:
76+
month = 1
77+
year += 1
78+
cursor = cursor.replace(year=year, month=month, day=1)
79+
elif period == "weekly":
80+
cursor = cursor + dt.timedelta(days=7)
81+
else:
82+
cursor = cursor + dt.timedelta(days=1)
83+
safety += 1
84+
return buckets
85+
86+
5087
def compute_dashboard_stats(
5188
db: Session,
5289
user_id: int,
5390
*,
5491
include_market: bool = False,
92+
range_start: dt.datetime | None = None,
93+
range_end: dt.datetime | None = None,
94+
period: Period = "daily",
5595
) -> dict[str, Any]:
56-
"""Profit windows, rankings, Vinted revenue, optional sum of Cardmarket refs for inventory."""
96+
"""Profit, revenue, channel split and per-period revenue timeline.
97+
98+
The ``range_start``/``range_end``/``period`` arguments drive the windowed
99+
metrics ("profit_period_eur", "revenue_period_eur", split per channel and
100+
the ``revenue_timeline`` used by the chart). Totals (profit_total_eur,
101+
inventory…) ignore the range and reflect the whole catalogue.
102+
"""
57103
now = dt.datetime.now(dt.UTC)
58-
week_start = now - dt.timedelta(days=7)
59-
month_start = now - dt.timedelta(days=30)
104+
if range_end is None:
105+
range_end = now
106+
if range_start is None:
107+
range_start = range_end - dt.timedelta(days=14)
108+
range_start = _as_utc(range_start) or now
109+
range_end = _as_utc(range_end) or now
110+
if range_start > range_end:
111+
range_start, range_end = range_end, range_start
112+
# Make the end inclusive (cover the full last day)
113+
range_end_inclusive = range_end.replace(hour=23, minute=59, second=59, microsecond=999_999)
60114

61115
rows = article_service.list_articles_for_user(db, user_id)
62116
sold = [a for a in rows if a.is_sold]
63117

64-
def in_week(a: Article) -> bool:
118+
def in_range(a: Article) -> bool:
65119
sold_at = _as_utc(a.sold_at)
66-
return bool(sold_at and sold_at >= week_start)
120+
return bool(sold_at and range_start <= sold_at <= range_end_inclusive)
67121

68-
def in_month(a: Article) -> bool:
69-
sold_at = _as_utc(a.sold_at)
70-
return bool(sold_at and sold_at >= month_start)
122+
sold_in_range = [a for a in sold if in_range(a)]
71123

72-
profit_week = sum(_profit_eur(a) for a in sold if in_week(a))
73-
profit_month = sum(_profit_eur(a) for a in sold if in_month(a))
74-
profit_total = sum(_profit_eur(a) for a in sold)
124+
profit_period = sum(_profit_eur(a) for a in sold_in_range)
125+
revenue_period = sum(
126+
p for a in sold_in_range for p in [_sale_proceeds_eur(a)] if p is not None
127+
)
128+
period_sales_count = len(sold_in_range)
75129

76-
vinted_revenue = sum(
130+
profit_total = sum(_profit_eur(a) for a in sold)
131+
vinted_revenue_total = sum(
77132
p for a in sold for p in [_sale_proceeds_eur(a)] if p is not None
78133
)
79134

135+
# Channel breakdown over the selected range (Vinted vs eBay)
136+
vinted_count = sum(1 for a in sold_in_range if a.sale_source == "vinted")
137+
ebay_count = sum(1 for a in sold_in_range if a.sale_source == "ebay")
138+
vinted_revenue_period = sum(
139+
p
140+
for a in sold_in_range
141+
if a.sale_source == "vinted"
142+
for p in [_sale_proceeds_eur(a)]
143+
if p is not None
144+
)
145+
ebay_revenue_period = sum(
146+
p
147+
for a in sold_in_range
148+
if a.sale_source == "ebay"
149+
for p in [_sale_proceeds_eur(a)]
150+
if p is not None
151+
)
152+
153+
# Same split across all-time (used for the Vinted vs eBay pie)
154+
vinted_count_total = sum(1 for a in sold if a.sale_source == "vinted")
155+
ebay_count_total = sum(1 for a in sold if a.sale_source == "ebay")
156+
vinted_revenue_total_split = sum(
157+
p
158+
for a in sold
159+
if a.sale_source == "vinted"
160+
for p in [_sale_proceeds_eur(a)]
161+
if p is not None
162+
)
163+
ebay_revenue_total_split = sum(
164+
p
165+
for a in sold
166+
if a.sale_source == "ebay"
167+
for p in [_sale_proceeds_eur(a)]
168+
if p is not None
169+
)
170+
80171
top_profitable = sorted(sold, key=_profit_eur, reverse=True)[:5]
81172
with_duration = [(a, _hours_to_sell(a)) for a in sold]
82173
with_duration = [(a, h) for a, h in with_duration if h is not None]
@@ -88,28 +179,30 @@ def in_month(a: Article) -> bool:
88179
inventory_sell_total_eur = sum(
89180
float(a.sell_price) for a in unsold if a.sell_price is not None
90181
)
182+
inventory_estimated_profit_eur = sum(
183+
float(a.sell_price) - float(a.purchase_price)
184+
for a in unsold
185+
if a.sell_price is not None
186+
)
91187

92-
# Cumulative revenue by month (sell price of sold articles)
93-
monthly_revenue: dict[str, float] = {}
94-
for a in sold:
188+
# Revenue timeline: bucketed per period within the selected range
189+
buckets = _iter_buckets(range_start, range_end_inclusive, period)
190+
bucket_totals: dict[dt.datetime, float] = {b: 0.0 for b in buckets}
191+
for a in sold_in_range:
95192
sold_at = _as_utc(a.sold_at)
96193
proceeds = _sale_proceeds_eur(a)
97194
if sold_at is None or proceeds is None:
98195
continue
99-
key = sold_at.strftime("%Y-%m")
100-
monthly_revenue[key] = monthly_revenue.get(key, 0.0) + proceeds
101-
revenue_timeline: list[dict[str, Any]] = []
102-
cumulative_rev = 0.0
103-
for month_key in sorted(monthly_revenue.keys()):
104-
incremental = monthly_revenue[month_key]
105-
cumulative_rev += incremental
106-
revenue_timeline.append(
107-
{
108-
"month": month_key,
109-
"revenue_month_eur": round(incremental, 2),
110-
"revenue_cumulative_eur": round(cumulative_rev, 2),
111-
}
112-
)
196+
key = _bucket_key(sold_at, period)
197+
if key in bucket_totals:
198+
bucket_totals[key] += proceeds
199+
revenue_timeline: list[dict[str, Any]] = [
200+
{
201+
"date": b.isoformat(),
202+
"revenue_eur": round(v, 2),
203+
}
204+
for b, v in bucket_totals.items()
205+
]
113206

114207
recent_sales = sorted(
115208
sold,
@@ -154,13 +247,32 @@ def in_month(a: Article) -> bool:
154247
market_sum_unsold += v
155248

156249
return {
157-
"profit_week_eur": round(profit_week, 2),
158-
"profit_month_eur": round(profit_month, 2),
250+
"range": {
251+
"start": range_start.isoformat(),
252+
"end": range_end.isoformat(),
253+
"period": period,
254+
},
255+
"profit_period_eur": round(profit_period, 2),
256+
"revenue_period_eur": round(revenue_period, 2),
257+
"period_sales_count": period_sales_count,
159258
"profit_total_eur": round(profit_total, 2),
160-
"vinted_revenue_eur": round(vinted_revenue, 2),
259+
"vinted_revenue_eur": round(vinted_revenue_total, 2),
260+
"channel_split_period": {
261+
"vinted_count": vinted_count,
262+
"ebay_count": ebay_count,
263+
"vinted_revenue_eur": round(vinted_revenue_period, 2),
264+
"ebay_revenue_eur": round(ebay_revenue_period, 2),
265+
},
266+
"channel_split_total": {
267+
"vinted_count": vinted_count_total,
268+
"ebay_count": ebay_count_total,
269+
"vinted_revenue_eur": round(vinted_revenue_total_split, 2),
270+
"ebay_revenue_eur": round(ebay_revenue_total_split, 2),
271+
},
161272
"inventory_count": inventory_count,
162273
"inventory_purchase_total_eur": round(inventory_purchase_total_eur, 2),
163274
"inventory_sell_total_eur": round(inventory_sell_total_eur, 2),
275+
"inventory_estimated_profit_eur": round(inventory_estimated_profit_eur, 2),
164276
"estimated_cardmarket_inventory_eur": round(market_sum, 2) if market_sum is not None else None,
165277
"estimated_cardmarket_unsold_eur": round(market_sum_unsold, 2)
166278
if market_sum_unsold is not None

todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +0,0 @@
1-
demande d'accès fonctionelle

web/app/components/dashboard/Charts.client.vue

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@ const eur = new Intl.NumberFormat('fr-FR', {
1010
currency: 'EUR',
1111
maximumFractionDigits: 0
1212
})
13+
14+
function formatTimeToSell(hours: number): string {
15+
if (!Number.isFinite(hours) || hours < 0) {
16+
return ''
17+
}
18+
if (hours < 24) {
19+
const rounded = Math.max(1, Math.round(hours))
20+
return `${rounded} h`
21+
}
22+
const days = Math.round(hours / 24)
23+
return `${days} j`
24+
}
1325
</script>
1426

1527
<template>
@@ -29,7 +41,7 @@ const eur = new Intl.NumberFormat('fr-FR', {
2941
:key="r.article_id"
3042
class="flex justify-between gap-2 py-2.5 text-sm"
3143
>
32-
<span class="truncate text-highlighted">{{ r.pokemon_name || r.title }}</span>
44+
<span class="truncate text-highlighted">{{ r.title }}</span>
3345
<span class="shrink-0 font-semibold text-primary">{{ eur.format(r.profit_eur) }}</span>
3446
</li>
3547
</ul>
@@ -53,8 +65,8 @@ const eur = new Intl.NumberFormat('fr-FR', {
5365
:key="r.article_id"
5466
class="flex justify-between gap-2 py-2.5 text-sm"
5567
>
56-
<span class="truncate">{{ r.pokemon_name || r.title }}</span>
57-
<span class="shrink-0 tabular-nums text-muted">{{ r.hours_to_sell }} h</span>
68+
<span class="truncate">{{ r.title }}</span>
69+
<span class="shrink-0 tabular-nums text-muted">{{ formatTimeToSell(r.hours_to_sell) }}</span>
5870
</li>
5971
</ul>
6072
<p v-else class="text-sm text-muted py-6 text-center">

0 commit comments

Comments
 (0)