Skip to content

Commit f51be81

Browse files
Revenue chart: readable values, week/month/year views, year boundaries, history paging (#516)
* feat(dashboard): make the revenue chart readable and navigable The monthly revenue chart could only be read by hovering each bar, was fixed to a single 12-month window, and gave no indication of where one calendar year ended and the next began. Bar totals are now printed above the bars, thinning out as bars get denser so labels never collide. A granularity control switches between week, month and year buckets, prev/next buttons page through history, and years are separated by an alternating background band plus a year row under the axis. The legend chips isolate or toggle series, so a single stack category can be read on its own. This needed month bucketing lifted out of the backend: revenue_from_ calendar now takes a pandas frequency, and a new revenue_series reconciles invoice-derived and calendar-derived revenue server-side. That replaces the frontend join of two RPC calls on a formatted "MM/YY" display string, and lets the chart page against the real data extent. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(dashboard): polish revenue chart issues found in UI smoke Three fixes from screenshotting the chart in every granularity: - Sub-thousand bar labels showed a pointless decimal (€903.6) and crowded their neighbours; compact notation has no suffix to shorten below a thousand, so drop the fraction digits there. - Year view shaded alternating bars, because every bucket is its own year there — skip the year banding entirely in that view. - Cap bar width so the three-bar year view doesn't draw slabs, and don't repeat the year on both ends of a single-year window label. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8fa9659 commit f51be81

6 files changed

Lines changed: 757 additions & 104 deletions

File tree

tuttle/app/dashboard/intent.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
cash_flow_projection,
77
monthly_revenue_from_calendar,
88
revenue_curve_with_calendar,
9+
revenue_series,
910
)
1011
from ...kpi import (
1112
compute_kpis,
@@ -106,6 +107,26 @@ def get_monthly_chart_data(self, n_months: int = 12) -> IntentResult:
106107
exception=e,
107108
)
108109

110+
def get_revenue_series(self, granularity: str = "month", offset: int = 0) -> IntentResult:
111+
"""Revenue per week/month/year bucket for one window of the revenue chart."""
112+
try:
113+
data = revenue_series(
114+
self.query(Invoice),
115+
self.query(Project),
116+
self._time_data_source.get_data_frame(),
117+
granularity=granularity,
118+
offset=int(offset),
119+
country=self._get_country(),
120+
)
121+
return IntentResult(was_intent_successful=True, data=data)
122+
except Exception as e:
123+
return IntentResult(
124+
was_intent_successful=False,
125+
error_msg=f"Failed to load revenue series: {e}",
126+
log_message=f"DashboardIntent.get_revenue_series: {e}",
127+
exception=e,
128+
)
129+
109130
def get_revenue_curve(self, forecast_months: int = 6) -> IntentResult:
110131
"""Get combined historical + calendar-based + contract-fallback revenue curve."""
111132
try:

tuttle/forecasting.py

Lines changed: 197 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
"""Revenue forecasting based on contracts, time allocation, and invoices."""
22

3+
import calendar
34
import datetime
45
from decimal import Decimal
56
from typing import List, Optional
67

78
import pandas
89
from pandas import DataFrame
910

11+
from .fx import primary_currency
1012
from .model import Contract, Invoice, Project
13+
from .tax_reserves import convert_invoice
1114
from .time import TimeUnit
1215
from .timetracking import event_hours
1316

@@ -159,19 +162,21 @@ def _invoiced_ranges_by_tag(invoices: List[Invoice]) -> dict:
159162
return ranges
160163

161164

162-
def monthly_revenue_from_calendar(
163-
time_data: DataFrame,
165+
def revenue_from_calendar(
166+
time_data: Optional[DataFrame],
164167
projects: List[Project],
165168
start_date: datetime.date,
166169
end_date: datetime.date,
167170
invoices: Optional[List[Invoice]] = None,
171+
freq: str = "M",
168172
) -> DataFrame:
169-
"""Derive monthly revenue from calendar time-tracking events.
173+
"""Derive revenue per time bucket from calendar time-tracking events.
170174
171175
The calendar DataFrame is the source of truth for hours worked (past)
172176
and hours planned (future). Filters *time_data* for events in
173-
[start_date, end_date], groups by month and project tag, then converts
174-
hours to revenue via contract rates.
177+
[start_date, end_date], groups by *freq* period and project tag, then
178+
converts hours to revenue via contract rates. *freq* is a pandas
179+
period alias — "W", "M" or "Y".
175180
176181
If *invoices* is given, hours already captured in a timesheet attached
177182
to a (non-cancelled) invoice are excluded, keyed by the timesheet's own
@@ -180,18 +185,19 @@ def monthly_revenue_from_calendar(
180185
"planned" for the month it was done, and again as "invoiced" for the
181186
month the invoice was actually raised.
182187
183-
Returns a DataFrame with columns: month, project, revenue, contract_id, hours.
188+
Returns a DataFrame with columns: period, project, revenue, contract_id, hours.
184189
"""
190+
empty = DataFrame(columns=["period", "project", "revenue", "contract_id", "hours"])
185191
if time_data is None or time_data.empty:
186-
return DataFrame(columns=["month", "project", "revenue", "contract_id", "hours"])
192+
return empty
187193

188194
tag_to_project = {p.tag: p for p in projects if p.tag and p.contract}
189195

190196
index_dates = time_data.index.date
191197
mask = (index_dates >= start_date) & (index_dates <= end_date)
192198
filtered = time_data[mask]
193199
if filtered.empty:
194-
return DataFrame(columns=["month", "project", "revenue", "contract_id", "hours"])
200+
return empty
195201

196202
if invoices:
197203
invoiced_ranges = _invoiced_ranges_by_tag(invoices)
@@ -203,11 +209,11 @@ def monthly_revenue_from_calendar(
203209
]
204210
filtered = filtered[[not v for v in already_invoiced]]
205211
if filtered.empty:
206-
return DataFrame(columns=["month", "project", "revenue", "contract_id", "hours"])
212+
return empty
207213

208214
records = []
209215
df = filtered.copy()
210-
df["_month"] = pandas.to_datetime(df.index).to_period("M").to_timestamp()
216+
df["_period"] = pandas.to_datetime(df.index).to_period(freq).to_timestamp()
211217
df["_hours"] = df.apply(
212218
lambda row: event_hours(
213219
row,
@@ -216,7 +222,7 @@ def monthly_revenue_from_calendar(
216222
axis=1,
217223
)
218224

219-
grouped = df.groupby(["_month", "tag"]).agg(hours=("_hours", "sum")).reset_index()
225+
grouped = df.groupby(["_period", "tag"]).agg(hours=("_hours", "sum")).reset_index()
220226
for _, row in grouped.iterrows():
221227
tag = row["tag"]
222228
project = tag_to_project.get(tag)
@@ -228,7 +234,7 @@ def monthly_revenue_from_calendar(
228234
revenue = float(Decimal(str(billable_units)) * contract.rate)
229235
records.append(
230236
{
231-
"month": row["_month"],
237+
"period": row["_period"],
232238
"project": project.title,
233239
"revenue": revenue,
234240
"contract_id": contract.id,
@@ -237,10 +243,25 @@ def monthly_revenue_from_calendar(
237243
)
238244

239245
if not records:
240-
return DataFrame(columns=["month", "project", "revenue", "contract_id", "hours"])
246+
return empty
241247
return DataFrame(records)
242248

243249

250+
def monthly_revenue_from_calendar(
251+
time_data: Optional[DataFrame],
252+
projects: List[Project],
253+
start_date: datetime.date,
254+
end_date: datetime.date,
255+
invoices: Optional[List[Invoice]] = None,
256+
) -> DataFrame:
257+
"""Monthly view of :func:`revenue_from_calendar`, keyed by ``month``.
258+
259+
Returns a DataFrame with columns: month, project, revenue, contract_id, hours.
260+
"""
261+
df = revenue_from_calendar(time_data, projects, start_date, end_date, invoices=invoices, freq="M")
262+
return df.rename(columns={"period": "month"})
263+
264+
244265
def cash_flow_projection(
245266
revenue_forecast: DataFrame,
246267
contracts: List[Contract],
@@ -326,3 +347,166 @@ def revenue_curve_with_calendar(
326347
combined = combined.sort_values("month").reset_index(drop=True)
327348
combined["cumulative_revenue"] = combined["revenue"].cumsum()
328349
return combined
350+
351+
352+
# Bucket sizes per granularity: pandas period alias, buckets per window, and
353+
# how many of those buckets sit in the future when viewing the present.
354+
_GRANULARITY = {
355+
"week": ("W", 13, 3),
356+
"month": ("M", 16, 3),
357+
"year": ("Y", 0, 0),
358+
}
359+
360+
361+
def revenue_window(
362+
granularity: str,
363+
offset: int = 0,
364+
today: Optional[datetime.date] = None,
365+
) -> tuple:
366+
"""Start and end date of the visible window for a paged revenue chart.
367+
368+
*offset* pages the window: 0 is the window containing today, -1 the one
369+
before it, and so on. A month window spans 16 buckets and a week window
370+
13, both reaching three buckets into the future at offset 0 so planned
371+
work is visible. Any 16 consecutive months contain a January, so the
372+
month view always has a year boundary on screen.
373+
"""
374+
today = today or datetime.date.today()
375+
freq, size, ahead = _GRANULARITY[granularity]
376+
if size == 0:
377+
raise ValueError(f"{granularity} is not a paged granularity")
378+
379+
current = pandas.Period(today, freq=freq)
380+
last = current + ahead + offset * size
381+
first = last - (size - 1)
382+
return first.start_time.date(), last.end_time.date()
383+
384+
385+
def _data_extent(
386+
invoices: List[Invoice],
387+
time_data: Optional[DataFrame],
388+
) -> tuple:
389+
"""Earliest and latest date covered by invoices or calendar events."""
390+
dates = [inv.date for inv in invoices if not inv.cancelled and inv.date]
391+
if time_data is not None and not time_data.empty:
392+
dates.append(time_data.index.min().date())
393+
dates.append(time_data.index.max().date())
394+
if not dates:
395+
return None, None
396+
return min(dates), max(dates)
397+
398+
399+
def _bucket_label(start: datetime.date, granularity: str) -> str:
400+
if granularity == "week":
401+
return f"W{start.isocalendar()[1]:02d}"
402+
if granularity == "year":
403+
return str(start.year)
404+
return calendar.month_abbr[start.month]
405+
406+
407+
def revenue_series(
408+
invoices: List[Invoice],
409+
projects: List[Project],
410+
time_data: Optional[DataFrame],
411+
granularity: str = "month",
412+
offset: int = 0,
413+
country: str = "",
414+
today: Optional[datetime.date] = None,
415+
) -> dict:
416+
"""Received, invoiced and planned revenue per bucket for one chart window.
417+
418+
Reconciles the two revenue sources the dashboard chart needs into a
419+
single series so the frontend does not have to join them: invoices give
420+
``received`` (paid) and ``invoiced`` (sent but unpaid) keyed by invoice
421+
date, while the calendar gives ``planned`` — tracked or scheduled work
422+
that no timesheet has billed yet, in the past as well as the future.
423+
424+
*granularity* is "week", "month" or "year". Week and month windows are
425+
paged with *offset*; the year window always spans the full data extent.
426+
Empty buckets are included so the time axis stays continuous.
427+
"""
428+
if granularity not in _GRANULARITY:
429+
raise ValueError(f"unknown granularity: {granularity}")
430+
431+
today = today or datetime.date.today()
432+
freq = _GRANULARITY[granularity][0]
433+
currency = primary_currency(country)
434+
extent_start, extent_end = _data_extent(invoices, time_data)
435+
436+
if granularity == "year":
437+
first_year = min(extent_start.year if extent_start else today.year, today.year)
438+
last_year = max(extent_end.year if extent_end else today.year, today.year)
439+
window_start = datetime.date(first_year, 1, 1)
440+
window_end = datetime.date(last_year, 12, 31)
441+
else:
442+
window_start, window_end = revenue_window(granularity, offset, today=today)
443+
444+
periods = pandas.period_range(start=window_start, end=window_end, freq=freq)
445+
buckets = {
446+
p: {
447+
"bucket": p.start_time.date().isoformat(),
448+
"bucket_end": p.end_time.date().isoformat(),
449+
"label": _bucket_label(p.start_time.date(), granularity),
450+
"year": p.start_time.year,
451+
"received": 0.0,
452+
"invoiced": 0.0,
453+
"planned": 0.0,
454+
"invoice_count": 0,
455+
"hours": 0.0,
456+
}
457+
for p in periods
458+
}
459+
460+
for inv in invoices:
461+
if inv.cancelled or not inv.date:
462+
continue
463+
period = pandas.Period(inv.date, freq=freq)
464+
bucket = buckets.get(period)
465+
if bucket is None:
466+
continue
467+
converted = convert_invoice(inv, currency)
468+
if converted is None:
469+
continue
470+
if inv.paid:
471+
bucket["received"] += float(converted[0])
472+
bucket["invoice_count"] += 1
473+
elif inv.sent:
474+
bucket["invoiced"] += float(converted[0])
475+
bucket["invoice_count"] += 1
476+
477+
cal = revenue_from_calendar(time_data, projects, window_start, window_end, invoices=invoices, freq=freq)
478+
if not cal.empty:
479+
grouped = cal.groupby("period").agg(revenue=("revenue", "sum"), hours=("hours", "sum")).reset_index()
480+
for _, row in grouped.iterrows():
481+
bucket = buckets.get(pandas.Period(row["period"], freq=freq))
482+
if bucket is None:
483+
continue
484+
bucket["planned"] += max(0.0, float(row["revenue"]))
485+
bucket["hours"] += float(row["hours"])
486+
487+
current_period = pandas.Period(today, freq=freq)
488+
rows = []
489+
previous_year = None
490+
for period in periods:
491+
row = buckets[period]
492+
row["is_current"] = period == current_period
493+
row["is_future"] = period > current_period
494+
row["is_year_start"] = previous_year is not None and row["year"] != previous_year
495+
row["total"] = round(row["received"] + row["invoiced"] + row["planned"], 2)
496+
for key in ("received", "invoiced", "planned", "hours"):
497+
row[key] = round(row[key], 2)
498+
previous_year = row["year"]
499+
rows.append(row)
500+
501+
paged = granularity != "year"
502+
return {
503+
"granularity": granularity,
504+
"offset": offset,
505+
"currency": currency,
506+
"window_start": window_start.isoformat(),
507+
"window_end": window_end.isoformat(),
508+
"buckets": rows,
509+
"total": round(sum(r["total"] for r in rows), 2),
510+
"has_earlier": bool(paged and extent_start and extent_start < window_start),
511+
"has_later": bool(paged and offset < 0),
512+
}

0 commit comments

Comments
 (0)