diff --git a/tuttle/app/dashboard/__init__.py b/tuttle/app/dashboard/__init__.py new file mode 100644 index 0000000..34f328d --- /dev/null +++ b/tuttle/app/dashboard/__init__.py @@ -0,0 +1,2 @@ +from .intent import DashboardIntent +from .view import DashboardView diff --git a/tuttle/app/dashboard/intent.py b/tuttle/app/dashboard/intent.py new file mode 100644 index 0000000..499b46e --- /dev/null +++ b/tuttle/app/dashboard/intent.py @@ -0,0 +1,114 @@ +"""Business logic for the dashboard view.""" + + +from ..core.abstractions import SQLModelDataSourceMixin, Intent +from ..core.intent_result import IntentResult + +from ...model import Contract, Invoice, Project, FinancialGoal +from ...kpi import compute_kpis, monthly_revenue_breakdown, project_budget_status +from ...forecasting import revenue_curve + + +class DashboardIntent(SQLModelDataSourceMixin, Intent): + """Gathers data for the freelance business dashboard.""" + + def __init__(self): + SQLModelDataSourceMixin.__init__(self) + + def get_kpis(self) -> IntentResult: + """Compute KPI summary from all invoices, contracts, projects.""" + try: + invoices = self.query(Invoice) + contracts = self.query(Contract) + projects = self.query(Project) + kpis = compute_kpis(invoices, contracts, projects) + return IntentResult(was_intent_successful=True, data=kpis) + except Exception as e: + return IntentResult( + was_intent_successful=False, + error_msg="Failed to compute KPIs.", + log_message=f"DashboardIntent.get_kpis: {e}", + exception=e, + ) + + def get_monthly_revenue(self, n_months: int = 12) -> IntentResult: + """Get monthly revenue breakdown for the last n months.""" + try: + invoices = self.query(Invoice) + data = monthly_revenue_breakdown(invoices, n_months=n_months) + return IntentResult(was_intent_successful=True, data=data) + except Exception as e: + return IntentResult( + was_intent_successful=False, + error_msg="Failed to load monthly revenue.", + log_message=f"DashboardIntent.get_monthly_revenue: {e}", + exception=e, + ) + + def get_revenue_curve(self, forecast_months: int = 6) -> IntentResult: + """Get combined historical + forecast revenue curve.""" + try: + invoices = self.query(Invoice) + contracts = self.query(Contract) + data = revenue_curve(invoices, contracts, forecast_months=forecast_months) + return IntentResult(was_intent_successful=True, data=data) + except Exception as e: + return IntentResult( + was_intent_successful=False, + error_msg="Failed to generate revenue forecast.", + log_message=f"DashboardIntent.get_revenue_curve: {e}", + exception=e, + ) + + def get_project_budgets(self) -> IntentResult: + """Get budget utilization for all projects.""" + try: + projects = self.query(Project) + data = project_budget_status(projects) + return IntentResult(was_intent_successful=True, data=data) + except Exception as e: + return IntentResult( + was_intent_successful=False, + error_msg="Failed to load project budgets.", + log_message=f"DashboardIntent.get_project_budgets: {e}", + exception=e, + ) + + def get_financial_goals(self) -> IntentResult: + """Load all financial goals.""" + try: + goals = self.query(FinancialGoal) + return IntentResult(was_intent_successful=True, data=goals) + except Exception as e: + return IntentResult( + was_intent_successful=False, + error_msg="Failed to load financial goals.", + log_message=f"DashboardIntent.get_financial_goals: {e}", + exception=e, + ) + + def save_financial_goal(self, goal: FinancialGoal) -> IntentResult: + """Save a financial goal.""" + try: + self.store(goal) + return IntentResult(was_intent_successful=True, data=goal) + except Exception as e: + return IntentResult( + was_intent_successful=False, + error_msg="Failed to save financial goal.", + log_message=f"DashboardIntent.save_financial_goal: {e}", + exception=e, + ) + + def delete_financial_goal(self, goal_id: int) -> IntentResult: + """Delete a financial goal by ID.""" + try: + self.delete_by_id(FinancialGoal, goal_id) + return IntentResult(was_intent_successful=True, data=None) + except Exception as e: + return IntentResult( + was_intent_successful=False, + error_msg="Failed to delete financial goal.", + log_message=f"DashboardIntent.delete_financial_goal: {e}", + exception=e, + ) diff --git a/tuttle/app/dashboard/view.py b/tuttle/app/dashboard/view.py new file mode 100644 index 0000000..29fab37 --- /dev/null +++ b/tuttle/app/dashboard/view.py @@ -0,0 +1,558 @@ +"""Dashboard view — Flet-native business overview. + +Renders KPI cards, revenue bar charts, project budget progress bars, +and financial goal tracking using only Flet controls (no browser). +""" + +from decimal import Decimal + +from flet import ( + Column, + Container, + CrossAxisAlignment, + Icon, + Icons, + MainAxisAlignment, + Padding, + ResponsiveRow, + Row, + ScrollMode, + Text, + TextAlign, + TextStyle, +) + +from ..core.abstractions import TView, TViewParams +from ..core import views +from ..res import colors, dimens, fonts, res_utils +from .intent import DashboardIntent + + +def _fmt_currency(value, symbol="€") -> str: + """Format a Decimal or float as a currency string.""" + if value is None: + return "—" + v = float(value) + if abs(v) >= 1000: + return f"{symbol}{v:,.0f}" + return f"{symbol}{v:,.2f}" + + +def _fmt_pct(value) -> str: + if value is None: + return "—" + return f"{value * 100:.0f}%" + + +# ── KPI Card ────────────────────────────────────────────────── + + +class _KPICard(Container): + """A single KPI metric card.""" + + def __init__( + self, + title: str, + value: str, + icon=Icons.INFO_OUTLINE, + value_color: str = colors.text_primary, + ): + super().__init__( + bgcolor=colors.bg_surface, + border_radius=dimens.RADIUS_LG, + padding=Padding.all(dimens.SPACE_STD), + col={"xs": 12, "sm": 6, "md": 4, "lg": 3}, + content=Column( + spacing=dimens.SPACE_XS, + controls=[ + Row( + spacing=dimens.SPACE_XS, + controls=[ + Icon( + icon, size=dimens.SM_ICON_SIZE, color=colors.text_muted + ), + Text( + title.upper(), + size=fonts.CAPTION_SIZE, + color=colors.text_muted, + weight=fonts.BOLD_FONT, + style=TextStyle(letter_spacing=0.8), + ), + ], + ), + Text( + value, + size=fonts.HEADLINE_2_SIZE, + color=value_color, + weight=fonts.BOLDER_FONT, + ), + ], + ), + ) + + +# ── Revenue Bar (native) ───────────────────────────────────── + + +_BAR_CHART_HEIGHT = 180 + + +class _VerticalBar(Column): + """A single vertical bar in the revenue chart.""" + + def __init__( + self, label: str, value: float, max_value: float, is_forecast: bool = False + ): + ratio = min(value / max_value, 1.0) if max_value > 0 else 0 + bar_height = max(ratio * _BAR_CHART_HEIGHT, 2) if ratio > 0 else 0 + empty_height = _BAR_CHART_HEIGHT - bar_height + bar_color = colors.accent if not is_forecast else colors.accent_muted + value_text = _fmt_currency(value) if value > 0 else "" + + super().__init__( + expand=True, + horizontal_alignment=CrossAxisAlignment.CENTER, + spacing=0, + controls=[ + # Value label above bar + Text( + value_text, + size=fonts.CAPTION_SIZE - 1, + color=colors.text_muted if is_forecast else colors.text_secondary, + text_align=TextAlign.CENTER, + ), + # Empty space above bar + Container(height=empty_height), + # The bar itself + Container( + height=bar_height, + bgcolor=bar_color, + border_radius=dimens.RADIUS_SM, + expand=True, + ), + # Month label below bar + Container( + padding=Padding.only(top=dimens.SPACE_XXS), + content=Text( + label, + size=fonts.CAPTION_SIZE, + color=colors.text_muted + if is_forecast + else colors.text_secondary, + text_align=TextAlign.CENTER, + ), + ), + ], + ) + + +# ── Project Budget Row ──────────────────────────────────────── + + +class _ProjectBudgetRow(Container): + """Progress bar for a single project's budget utilization.""" + + def __init__( + self, + project_name: str, + progress: float, + hours_tracked: float, + hours_budget: float, + ): + progress = min(progress, 1.0) + bar_color = ( + colors.success + if progress < 0.8 + else (colors.warning if progress < 1.0 else colors.danger) + ) + + super().__init__( + padding=Padding.symmetric(vertical=dimens.SPACE_XXS), + content=Column( + spacing=2, + controls=[ + Row( + alignment=MainAxisAlignment.SPACE_BETWEEN, + controls=[ + Text( + project_name, + size=fonts.BODY_1_SIZE, + color=colors.text_primary, + expand=True, + ), + Text( + f"{hours_tracked:.0f} / {hours_budget:.0f} h ({_fmt_pct(progress)})", + size=fonts.BODY_2_SIZE, + color=colors.text_secondary, + ), + ], + ), + Container( + height=6, + bgcolor=colors.border, + border_radius=dimens.RADIUS_PILL, + content=Row( + spacing=0, + controls=[ + Container( + expand=int(max(progress * 100, 1)), + height=6, + bgcolor=bar_color, + border_radius=dimens.RADIUS_PILL, + ), + Container(expand=int(max((1 - progress) * 100, 0))), + ], + ), + ), + ], + ), + ) + + +# ── Section Header ──────────────────────────────────────────── + + +def _section_header(title: str, icon=None) -> Container: + controls = [] + if icon: + controls.append( + Icon(icon, size=dimens.MD_ICON_SIZE, color=colors.text_secondary) + ) + controls.append( + Text( + title, + size=fonts.HEADLINE_4_SIZE, + color=colors.text_primary, + weight=fonts.BOLD_FONT, + ) + ) + return Container( + padding=Padding.only(top=dimens.SPACE_LG, bottom=dimens.SPACE_SM), + content=Row(spacing=dimens.SPACE_XS, controls=controls), + ) + + +# ── Main Dashboard View ────────────────────────────────────── + + +class DashboardView(TView, Column): + """Freelance business dashboard — the default landing view.""" + + def __init__(self, params: TViewParams): + TView.__init__(self, params) + Column.__init__(self) + self.intent = DashboardIntent() + self.scroll = ScrollMode.AUTO + self.spacing = 0 + self.expand = True + + def build(self): + self._kpi_row = ResponsiveRow( + spacing=dimens.SPACE_SM, run_spacing=dimens.SPACE_SM + ) + self._revenue_section = Column(spacing=0) + self._budget_section = Column(spacing=0) + self._goals_section = Column(spacing=0) + + self.controls = [ + Container( + padding=Padding.all(dimens.SPACE_MD), + content=Column( + spacing=dimens.SPACE_XS, + controls=[ + Text( + "Dashboard", + size=fonts.HEADLING_1_SIZE, + color=colors.text_primary, + weight=fonts.BOLDER_FONT, + ), + views.Spacer(sm_space=True), + self._kpi_row, + self._revenue_section, + self._budget_section, + self._goals_section, + ], + ), + ) + ] + + def did_mount(self): + self.mounted = True + self._load_data() + + def on_resume_after_back_pressed(self): + self._load_data() + + def parent_intent_listener(self, intent: str, data=None): + if intent == res_utils.RELOAD_INTENT: + self._load_data() + + def _load_data(self): + """Fetch all dashboard data and rebuild controls.""" + self._load_kpis() + self._load_revenue_chart() + self._load_project_budgets() + self._load_goals() + self.update_self() + + # ── KPI cards ───────────────────────────────────────────── + + def _load_kpis(self): + result = self.intent.get_kpis() + self._kpi_row.controls.clear() + if not result.was_intent_successful or result.data is None: + return + + kpis = result.data + cards = [ + _KPICard( + "Revenue (YTD)", + _fmt_currency(kpis.total_revenue_ytd), + Icons.TRENDING_UP, + colors.success if kpis.total_revenue_ytd > 0 else colors.text_primary, + ), + _KPICard( + "Outstanding", + _fmt_currency(kpis.outstanding_amount), + Icons.ACCOUNT_BALANCE_WALLET_OUTLINED, + colors.warning if kpis.outstanding_amount > 0 else colors.text_primary, + ), + _KPICard( + "Overdue", + _fmt_currency(kpis.overdue_amount), + Icons.WARNING_AMBER_ROUNDED, + colors.danger if kpis.overdue_amount > 0 else colors.text_primary, + ), + _KPICard( + "Eff. Hourly Rate", + _fmt_currency(kpis.effective_hourly_rate, "€") + if kpis.effective_hourly_rate + else "—", + Icons.SPEED, + colors.accent, + ), + _KPICard( + "Utilization", + _fmt_pct(kpis.utilization_rate), + Icons.PIE_CHART_OUTLINE, + colors.accent + if kpis.utilization_rate and kpis.utilization_rate >= 0.7 + else colors.warning, + ), + _KPICard( + "Active Projects", + str(kpis.active_projects), + Icons.WORK_OUTLINE, + ), + _KPICard( + "Active Contracts", + str(kpis.active_contracts), + Icons.HANDSHAKE_OUTLINED, + ), + _KPICard( + "Unpaid Invoices", + str(kpis.unpaid_invoices), + Icons.RECEIPT_OUTLINED, + colors.warning if kpis.unpaid_invoices > 0 else colors.text_primary, + ), + ] + self._kpi_row.controls.extend(cards) + + # ── Revenue chart ───────────────────────────────────────── + + def _load_revenue_chart(self): + self._revenue_section.controls.clear() + + result = self.intent.get_monthly_revenue(n_months=12) + if not result.was_intent_successful or not result.data: + return + + months = result.data + if not months: + return + + # Collect all bar data (history + forecast) to compute a shared max + bar_data = [] + for m in months: + rev = float(m["revenue"]) + if rev > 0: + # "YYYY-MM" → "MM\n'YY" + year, mon = m["month"].split("-") + label = f"{mon}\n'{year[2:]}" + bar_data.append((label, rev, False)) + + # Forecast + forecast_result = self.intent.get_revenue_curve(forecast_months=6) + if forecast_result.was_intent_successful and forecast_result.data is not None: + df = forecast_result.data + forecast_rows = df[df["is_forecast"].eq(True)] + for _, row in forecast_rows.iterrows(): + month_dt = row["month"] + if hasattr(month_dt, "strftime"): + label = month_dt.strftime("%m") + "*\n'" + month_dt.strftime("%y") + else: + s = str(month_dt) + label = s[5:7] + "*\n'" + s[2:4] + bar_data.append((label, float(row["revenue"]), True)) + + if not bar_data: + return + + max_val = max(v for _, v, _ in bar_data) + if max_val == 0: + max_val = 1 + + bars = [ + _VerticalBar(label, value, max_val, is_forecast=fc) + for label, value, fc in bar_data + ] + + self._revenue_section.controls = [ + _section_header("Monthly Revenue", Icons.BAR_CHART), + Container( + bgcolor=colors.bg_surface, + border_radius=dimens.RADIUS_LG, + padding=Padding.all(dimens.SPACE_STD), + content=Row( + spacing=dimens.SPACE_XXS, + vertical_alignment=CrossAxisAlignment.END, + controls=list(bars), + ), + ), + ] + + # ── Project budgets ─────────────────────────────────────── + + def _load_project_budgets(self): + self._budget_section.controls.clear() + + result = self.intent.get_project_budgets() + if not result.was_intent_successful or not result.data: + return + + rows = [ + _ProjectBudgetRow( + b["project"], + b["progress"], + b["hours_tracked"], + b["hours_budget"], + ) + for b in result.data + ] + + if not rows: + return + + self._budget_section.controls = [ + _section_header("Project Budgets", Icons.DONUT_LARGE), + Container( + bgcolor=colors.bg_surface, + border_radius=dimens.RADIUS_LG, + padding=Padding.all(dimens.SPACE_STD), + content=Column(spacing=dimens.SPACE_XS, controls=list(rows)), + ), + ] + + # ── Financial goals ─────────────────────────────────────── + + def _load_goals(self): + self._goals_section.controls.clear() + + result = self.intent.get_financial_goals() + if not result.was_intent_successful or not result.data: + return + + goals = result.data + if not goals: + return + + # For each goal, show progress toward target based on YTD revenue + kpi_result = self.intent.get_kpis() + ytd_revenue = Decimal(0) + if kpi_result.was_intent_successful and kpi_result.data: + ytd_revenue = kpi_result.data.total_revenue_ytd + + goal_rows = [] + for goal in goals: + progress = ( + float(ytd_revenue / goal.target_amount) if goal.target_amount > 0 else 0 + ) + progress = min(progress, 1.0) + bar_color = ( + colors.success + if goal.is_reached + else (colors.accent if progress < 1.0 else colors.success) + ) + status_text = ( + "Reached!" + if goal.is_reached + else f"{_fmt_currency(ytd_revenue)} / {_fmt_currency(goal.target_amount)}" + ) + + goal_rows.append( + Container( + padding=Padding.symmetric(vertical=dimens.SPACE_XXS), + content=Column( + spacing=2, + controls=[ + Row( + alignment=MainAxisAlignment.SPACE_BETWEEN, + controls=[ + Text( + goal.title, + size=fonts.BODY_1_SIZE, + color=colors.text_primary, + expand=True, + ), + Text( + status_text, + size=fonts.BODY_2_SIZE, + color=colors.success + if goal.is_reached + else colors.text_secondary, + ), + ], + ), + Row( + alignment=MainAxisAlignment.SPACE_BETWEEN, + controls=[ + Text( + f"Target: {_fmt_currency(goal.target_amount)} by {goal.target_date.strftime('%b %Y')}", + size=fonts.CAPTION_SIZE, + color=colors.text_muted, + ), + ], + ), + Container( + height=6, + bgcolor=colors.border, + border_radius=dimens.RADIUS_PILL, + content=Row( + spacing=0, + controls=[ + Container( + expand=int(max(progress * 100, 1)), + height=6, + bgcolor=bar_color, + border_radius=dimens.RADIUS_PILL, + ), + Container( + expand=int(max((1 - progress) * 100, 0)) + ), + ], + ), + ), + ], + ), + ) + ) + + self._goals_section.controls = [ + _section_header("Financial Goals", Icons.FLAG_OUTLINED), + Container( + bgcolor=colors.bg_surface, + border_radius=dimens.RADIUS_LG, + padding=Padding.all(dimens.SPACE_STD), + content=Column(spacing=dimens.SPACE_SM, controls=goal_rows), + ), + ] diff --git a/tuttle/app/home/view.py b/tuttle/app/home/view.py index 3c7c35d..fef25c6 100644 --- a/tuttle/app/home/view.py +++ b/tuttle/app/home/view.py @@ -35,6 +35,7 @@ from ..core import utils, views from ..core.abstractions import DialogHandler, TView, TViewParams from ..core.status_bar import StatusBarManager +from ..dashboard.view import DashboardView from ..invoicing.view import InvoicingListView from ..projects.view import ProjectsListView from ..res import colors, dimens, fonts, res_utils, theme @@ -49,7 +50,26 @@ def get_toolbar( on_view_settings_clicked: Callable, ): """Slim toolbar — actions only, no redundant title.""" - return Container( + new_btn = TextButton( + content=Row( + controls=[ + Icon( + Icons.ADD, + size=dimens.SM_ICON_SIZE, + color=colors.accent, + ), + Text( + "New", + size=fonts.BODY_1_SIZE, + color=colors.accent, + weight=fonts.BOLD_FONT, + ), + ], + spacing=dimens.SPACE_XXS, + ), + on_click=on_click_new_btn, + ) + toolbar = Container( alignment=Alignment.CENTER, height=dimens.TOOLBAR_HEIGHT, bgcolor=colors.bg, @@ -61,25 +81,7 @@ def get_toolbar( Row( spacing=dimens.SPACE_XXS, controls=[ - TextButton( - content=Row( - controls=[ - Icon( - Icons.ADD, - size=dimens.SM_ICON_SIZE, - color=colors.accent, - ), - Text( - "New", - size=fonts.BODY_1_SIZE, - color=colors.accent, - weight=fonts.BOLD_FONT, - ), - ], - spacing=dimens.SPACE_XXS, - ), - on_click=on_click_new_btn, - ), + new_btn, IconButton( icon=Icons.SETTINGS_OUTLINED, icon_size=dimens.ICON_SIZE, @@ -120,6 +122,7 @@ def get_toolbar( ], ), ) + return toolbar, new_btn class MainMenuItemsHandler: @@ -196,6 +199,24 @@ def __init__(self, params: TViewParams): ] +class InsightsMenuHandler: + """Manages home's insights-menu items (dashboard, KPIs).""" + + def __init__(self, params: TViewParams): + super().__init__() + self.menu_title = "Insights" + self.dashboard_view = DashboardView(params) + self.items = [ + views.NavigationMenuItem( + index=0, + label="Dashboard", + icon=utils.TuttleComponentIcons.dashboard_icon, + selected_icon=utils.TuttleComponentIcons.dashboard_selected_icon, + destination=self.dashboard_view, + ), + ] + + class HomeScreen(TView, Container): """Main app shell — sidebar + toolbar + content area + status bar.""" @@ -203,21 +224,28 @@ def __init__(self, params: TViewParams): super().__init__(params) self.keep_back_stack = False self.page_scroll_type = None + self.insights_menu_handler = InsightsMenuHandler(params) self.main_menu_handler = MainMenuItemsHandler(params) self.secondary_menu_handler = SecondaryMenuHandler(params) self.preferences_intent = PreferencesIntent( client_storage=params.client_storage ) - # Build flat list of all items for the sidebar - self._all_items: list[views.NavigationMenuItem] = list( - self.main_menu_handler.items - ) + list(self.secondary_menu_handler.items) + # Build flat list of all items for the sidebar — Dashboard first + self._all_items: list[views.NavigationMenuItem] = ( + list(self.insights_menu_handler.items) + + list(self.main_menu_handler.items) + + list(self.secondary_menu_handler.items) + ) self._selected_flat_index = 0 # Create sidebar panel self.sidebar_panel = views.SidebarPanel( sections=[ + ( + self.insights_menu_handler.menu_title, + self.insights_menu_handler.items, + ), (self.main_menu_handler.menu_title, self.main_menu_handler.items), ( self.secondary_menu_handler.menu_title, @@ -238,20 +266,27 @@ def __init__(self, params: TViewParams): ) # Toolbar (slim, no title — view heading is the title) - self.toolbar = get_toolbar( + self.toolbar, self._new_btn = get_toolbar( on_click_new_btn=self.on_click_add_new, on_click_profile_btn=self.on_click_profile, on_view_settings_clicked=self.on_view_settings_clicked, ) + self._update_new_btn_visibility() def _on_sidebar_item_selected(self, item: views.NavigationMenuItem): """Called when the user clicks a sidebar nav item.""" self._selected_flat_index = self._all_items.index(item) self.destination_view = item.destination self.destination_content_container.content = self.destination_view + self._update_new_btn_visibility() self._update_status_bar_for_view(item.label) self.update_self() + def _update_new_btn_visibility(self): + """Show the '+ New' button only for views that support it.""" + item = self._all_items[self._selected_flat_index] + self._new_btn.visible = bool(item.on_new_intent or item.on_new_screen_route) + # ── Action buttons ──────────────────────────────────────── def on_click_add_new(self, e): item = self._all_items[self._selected_flat_index] @@ -419,6 +454,8 @@ def _update_status_bar_for_view(self, view_label: str): self.status_bar_manager.update_warnings() elif view_label == "Time Tracking": count_text = "Time Tracking" + elif view_label == "Dashboard": + count_text = "Dashboard" except Exception: pass diff --git a/tuttle/dataviz.py b/tuttle/dataviz.py index 225b938..330680f 100644 --- a/tuttle/dataviz.py +++ b/tuttle/dataviz.py @@ -1,9 +1,14 @@ """Data visualization.""" +import datetime +from decimal import Decimal +from typing import List, Optional + import altair # from pandera.typing import DataFrame from pandas import DataFrame +import pandas from .dev import deprecated @@ -77,3 +82,105 @@ def plot_eval_time_planning( else: raise ValueError(f"unknown mode {by}") return plot + + +def plot_revenue_curve( + revenue_data: DataFrame, + goals: Optional[List[dict]] = None, +) -> altair.LayerChart: + """Plot historical + forecast revenue curve with optional goal markers. + + Args: + revenue_data: DataFrame with columns: month, revenue, is_forecast, cumulative_revenue. + goals: Optional list of dicts with keys: title, target_amount, target_date. + + Returns: + An Altair LayerChart. + """ + revenue_data = revenue_data.copy() + revenue_data["month"] = pandas.to_datetime(revenue_data["month"]) + revenue_data["type"] = revenue_data["is_forecast"].map( + {True: "Forecast", False: "Actual"} + ) + + # Monthly revenue bars + bars = ( + altair.Chart(revenue_data) + .mark_bar(opacity=0.7) + .encode( + x=altair.X("yearmonth(month):O", axis=altair.Axis(title="Month")), + y=altair.Y("revenue:Q", axis=altair.Axis(title="Revenue (€)")), + color=altair.Color( + "type:N", + scale=altair.Scale( + domain=["Actual", "Forecast"], + range=["#0A84FF", "#8E8E93"], + ), + legend=altair.Legend(title="Type"), + ), + ) + .properties(width=600, height=300) + ) + + # Cumulative revenue line + line = ( + altair.Chart(revenue_data) + .mark_line(strokeWidth=2, color="#30D158") + .encode( + x="yearmonth(month):O", + y=altair.Y("cumulative_revenue:Q", axis=altair.Axis(title="")), + ) + ) + + layer = bars + line + + # Goal markers + if goals: + goal_df = DataFrame(goals) + goal_df["target_date"] = pandas.to_datetime(goal_df["target_date"]) + goal_rules = ( + altair.Chart(goal_df) + .mark_rule(strokeDash=[4, 4], color="#FFD60A", strokeWidth=2) + .encode( + y="target_amount:Q", + ) + ) + goal_labels = ( + altair.Chart(goal_df) + .mark_text(align="left", dx=5, dy=-5, color="#FFD60A", fontSize=11) + .encode( + y="target_amount:Q", + text="title:N", + ) + ) + layer = layer + goal_rules + goal_labels + + return layer + + +def plot_monthly_revenue_bars( + monthly_data: list, +) -> altair.Chart: + """Simple monthly revenue bar chart for the KPI overview. + + Args: + monthly_data: List of dicts with keys: month, revenue. + """ + df = DataFrame(monthly_data) + df["revenue"] = df["revenue"].astype(float) + + chart = ( + altair.Chart(df) + .mark_bar(color="#0A84FF") + .encode( + x=altair.X("month:O", axis=altair.Axis(title="Month", labelAngle=-45)), + y=altair.Y("revenue:Q", axis=altair.Axis(title="Revenue (€)")), + ) + .properties(width=600, height=250) + ) + return chart + + +def chart_to_html(chart: altair.TopLevelMixin) -> str: + """Render an Altair chart to a self-contained HTML string for embedding.""" + return chart.to_html() diff --git a/tuttle/demo.py b/tuttle/demo.py index 21f2fb4..73a0593 100644 --- a/tuttle/demo.py +++ b/tuttle/demo.py @@ -23,6 +23,7 @@ Contact, Contract, Cycle, + FinancialGoal, Invoice, InvoiceItem, Timesheet, @@ -212,6 +213,10 @@ def create_fake_invoice( project: Optional[Project] = None, user: Optional[User] = None, render: bool = True, + invoice_date: Optional[date] = None, + paid: Optional[bool] = None, + sent: Optional[bool] = None, + cancelled: bool = False, ) -> Invoice: """ Create a fake invoice object with random values. @@ -219,6 +224,10 @@ def create_fake_invoice( Args: project (Project): The project associated with the invoice. fake (faker.Faker): An instance of the Faker class to generate random values. + invoice_date: The date for the invoice. Defaults to today. + paid: Whether the invoice is paid. Random if None. + sent: Whether the invoice was sent. Random if None. + cancelled: Whether the invoice is cancelled. Returns: Invoice: A fake invoice object. @@ -229,15 +238,14 @@ def create_fake_invoice( if user is None: user = create_fake_user(fake) - invoice_number = ( - f"{datetime.date.today().strftime('%Y-%m-%d')}-{next(invoice_number_counter)}" - ) + inv_date = invoice_date or datetime.date.today() + invoice_number = f"{inv_date.strftime('%Y-%m-%d')}-{next(invoice_number_counter)}" invoice = Invoice( number=invoice_number, - date=datetime.date.today(), - sent=fake.pybool(), - paid=fake.pybool(), - cancelled=fake.pybool(), + date=inv_date, + sent=sent if sent is not None else fake.pybool(), + paid=paid if paid is not None else fake.pybool(), + cancelled=cancelled, contract=project.contract, project=project, rendered=fake.pybool(), @@ -332,6 +340,37 @@ def create_fake_data( return projects, invoices +def create_historical_invoices( + fake: faker.Faker, + projects: List[Project], + user: User, + n_months: int = 11, +) -> List[Invoice]: + """Create invoices spread across the past n_months for dashboard history.""" + today = datetime.date.today() + invoices = [] + for months_ago in range(n_months, 0, -1): + # First day of that month + inv_date = (today - timedelta(days=30 * months_ago)).replace(day=15) + # Pick 1-2 random projects to invoice each month + month_projects = random.sample( + projects, k=min(random.randint(1, 2), len(projects)) + ) + for project in month_projects: + inv = create_fake_invoice( + fake, + project=project, + user=user, + render=False, + invoice_date=inv_date, + paid=True, + sent=True, + cancelled=False, + ) + invoices.append(inv) + return invoices + + def create_demo_user() -> User: user = User( name="Harry Tuttle", @@ -436,10 +475,40 @@ def install_demo_data( with Session(db_engine) as session: for invoice in invoices: session.add(invoice) - session.commit() + session.commit() - logger.info("Adding fake projects...") - with Session(db_engine) as session: + # add historical invoices in the same session so relationships resolve + logger.info("Adding historical invoices...") + locales = ["de_DE", "en_US", "en_GB", "fr_FR"] + fake = faker.Faker(locale=locales) + historical_invoices = create_historical_invoices( + fake, projects, user, n_months=11 + ) + for invoice in historical_invoices: + session.add(invoice) + session.commit() + + # add projects in the same session + logger.info("Adding fake projects...") for project in projects: - session.add(project) - session.commit() + session.merge(project) + session.commit() + + logger.info("Adding financial goals...") + with Session(db_engine) as session: + today = datetime.date.today() + goals = [ + FinancialGoal( + title="Yearly Revenue Target", + target_amount=Decimal("80000.00"), + target_date=today.replace(month=12, day=31), + ), + FinancialGoal( + title="Emergency Fund", + target_amount=Decimal("15000.00"), + target_date=today.replace(year=today.year + 1, month=6, day=30), + ), + ] + for goal in goals: + session.add(goal) + session.commit() diff --git a/tuttle/forecasting.py b/tuttle/forecasting.py new file mode 100644 index 0000000..fc49ab3 --- /dev/null +++ b/tuttle/forecasting.py @@ -0,0 +1,146 @@ +"""Revenue forecasting based on contracts, time allocation, and invoices.""" + +import datetime +from decimal import Decimal +from typing import List + +import pandas +from pandas import DataFrame + +from .model import Contract, Invoice + + +def monthly_revenue_from_contracts( + contracts: List[Contract], + start_date: datetime.date, + end_date: datetime.date, +) -> DataFrame: + """Project monthly revenue from active contracts and their rates. + + For each month in [start_date, end_date], estimates revenue based on + contract rate × volume distributed across the contract duration. + + Returns a DataFrame with columns: month, project, revenue, contract_id. + """ + records = [] + current = start_date.replace(day=1) + while current <= end_date: + month_end = (current + datetime.timedelta(days=32)).replace( + day=1 + ) - datetime.timedelta(days=1) + for contract in contracts: + if contract.start_date > month_end: + continue + if contract.end_date and contract.end_date < current: + continue + if contract.is_completed: + continue + + # Estimate workdays in this month (approx 22) + workdays_in_month = 22 + + if contract.volume and contract.start_date and contract.end_date: + # Distribute volume evenly across contract duration + total_days = (contract.end_date - contract.start_date).days or 1 + contract_months = max(total_days / 30.0, 1.0) + units_per_month = contract.volume / contract_months + else: + # Assume full-time allocation: workdays × units_per_workday + units_per_month = workdays_in_month * contract.units_per_workday + + monthly_revenue = Decimal(str(units_per_month)) * contract.rate + project_title = ( + contract.projects[0].title if contract.projects else contract.title + ) + + records.append( + { + "month": current, + "project": project_title, + "revenue": float(monthly_revenue), + "contract_id": contract.id, + } + ) + current = (current + datetime.timedelta(days=32)).replace(day=1) + + if not records: + return DataFrame(columns=["month", "project", "revenue", "contract_id"]) + return DataFrame(records) + + +def revenue_history( + invoices: List[Invoice], +) -> DataFrame: + """Build a monthly revenue history from past invoices. + + Returns a DataFrame with columns: month, revenue, invoice_count. + """ + if not invoices: + return DataFrame(columns=["month", "revenue", "invoice_count"]) + + records = [] + for inv in invoices: + if inv.cancelled: + continue + records.append( + { + "date": inv.date, + "revenue": float(inv.total), + } + ) + + if not records: + return DataFrame(columns=["month", "revenue", "invoice_count"]) + + df = DataFrame(records) + df["month"] = pandas.to_datetime(df["date"]).dt.to_period("M").dt.to_timestamp() + monthly = ( + df.groupby("month") + .agg( + revenue=("revenue", "sum"), + invoice_count=("revenue", "count"), + ) + .reset_index() + ) + return monthly + + +def revenue_curve( + invoices: List[Invoice], + contracts: List[Contract], + forecast_months: int = 6, +) -> DataFrame: + """Combine historical revenue with forecast into a single time series. + + Returns a DataFrame with columns: month, revenue, is_forecast. + """ + # Historical + history = revenue_history(invoices) + if not history.empty: + history["is_forecast"] = False + else: + history = DataFrame(columns=["month", "revenue", "is_forecast"]) + + # Forecast + today = datetime.date.today() + forecast_start = today.replace(day=1) + forecast_end = ( + forecast_start + datetime.timedelta(days=30 * forecast_months) + ).replace(day=1) + forecast = monthly_revenue_from_contracts(contracts, forecast_start, forecast_end) + if not forecast.empty: + forecast_monthly = ( + forecast.groupby("month").agg(revenue=("revenue", "sum")).reset_index() + ) + forecast_monthly["is_forecast"] = True + else: + forecast_monthly = DataFrame(columns=["month", "revenue", "is_forecast"]) + + combined = pandas.concat([history, forecast_monthly], ignore_index=True) + combined["month"] = pandas.to_datetime(combined["month"]) + combined = combined.sort_values("month").reset_index(drop=True) + + # Cumulative revenue + combined["cumulative_revenue"] = combined["revenue"].cumsum() + + return combined diff --git a/tuttle/kpi.py b/tuttle/kpi.py new file mode 100644 index 0000000..16e7d9e --- /dev/null +++ b/tuttle/kpi.py @@ -0,0 +1,158 @@ +"""Key Performance Indicators for freelance business analysis.""" + +import datetime +from decimal import Decimal +from typing import List, Optional, NamedTuple + +from .model import Contract, Invoice, Project + + +class KPISummary(NamedTuple): + """Snapshot of key business metrics.""" + + total_revenue: Decimal + total_revenue_ytd: Decimal + outstanding_amount: Decimal + overdue_amount: Decimal + effective_hourly_rate: Optional[Decimal] + utilization_rate: Optional[float] + active_projects: int + active_contracts: int + unpaid_invoices: int + overdue_invoices: int + + +def compute_kpis( + invoices: List[Invoice], + contracts: List[Contract], + projects: List[Project], +) -> KPISummary: + """Compute business KPIs from current data.""" + today = datetime.date.today() + year_start = today.replace(month=1, day=1) + + # Revenue metrics + total_revenue = Decimal(0) + total_revenue_ytd = Decimal(0) + outstanding_amount = Decimal(0) + overdue_amount = Decimal(0) + unpaid_invoices = 0 + overdue_invoices = 0 + total_hours = Decimal(0) + paid_revenue = Decimal(0) + + for inv in invoices: + if inv.cancelled: + continue + inv_total = inv.total + + if inv.paid: + total_revenue += inv_total + if inv.date >= year_start: + total_revenue_ytd += inv_total + # Accumulate hours for effective rate calculation + for ts in inv.timesheets: + for item in ts.items: + hours = item.duration.total_seconds() / 3600 + total_hours += Decimal(str(hours)) + paid_revenue += inv_total + else: + outstanding_amount += inv_total + unpaid_invoices += 1 + if inv.due_date and inv.due_date < today: + overdue_amount += inv_total + overdue_invoices += 1 + + # Effective hourly rate: paid revenue / total tracked hours + effective_hourly_rate = None + if total_hours > 0: + effective_hourly_rate = paid_revenue / total_hours + + # Active contracts / projects + active_contracts = sum(1 for c in contracts if c.is_active()) + active_projects = sum(1 for p in projects if p.is_active()) + + # Utilization rate: tracked hours / available hours (based on workdays) + utilization_rate = None + if active_contracts: + # Available hours since year start + days_elapsed = (today - year_start).days or 1 + workdays_elapsed = int(days_elapsed * 5 / 7) + # Sum contracted hours per workday across active contracts + available_hours_per_day = sum( + c.units_per_workday for c in contracts if c.is_active() + ) + available_hours = Decimal(str(workdays_elapsed * available_hours_per_day)) + if available_hours > 0: + utilization_rate = float(total_hours / available_hours) + + return KPISummary( + total_revenue=total_revenue, + total_revenue_ytd=total_revenue_ytd, + outstanding_amount=outstanding_amount, + overdue_amount=overdue_amount, + effective_hourly_rate=effective_hourly_rate, + utilization_rate=utilization_rate, + active_projects=active_projects, + active_contracts=active_contracts, + unpaid_invoices=unpaid_invoices, + overdue_invoices=overdue_invoices, + ) + + +def monthly_revenue_breakdown( + invoices: List[Invoice], + n_months: int = 12, +) -> list: + """Revenue breakdown by month for the last n_months. + + Returns a list of dicts with keys: month, revenue, invoice_count. + """ + today = datetime.date.today() + start = (today - datetime.timedelta(days=30 * n_months)).replace(day=1) + + months = {} + current = start + while current <= today: + key = current.strftime("%Y-%m") + months[key] = {"month": key, "revenue": Decimal(0), "invoice_count": 0} + current = (current + datetime.timedelta(days=32)).replace(day=1) + + for inv in invoices: + if inv.cancelled: + continue + key = inv.date.strftime("%Y-%m") + if key in months: + months[key]["revenue"] += inv.total + months[key]["invoice_count"] += 1 + + return sorted(months.values(), key=lambda x: x["month"]) + + +def project_budget_status( + projects: List[Project], +) -> list: + """Budget utilization for each project with timesheets. + + Returns a list of dicts with keys: project, hours_tracked, hours_budget, progress. + """ + results = [] + for project in projects: + if not project.contract or not project.contract.volume: + continue + hours_tracked = Decimal(0) + for ts in project.timesheets: + for item in ts.items: + hours_tracked += Decimal(str(item.duration.total_seconds() / 3600)) + hours_budget = Decimal(str(project.contract.volume)) + progress = float(hours_tracked / hours_budget) if hours_budget > 0 else 0.0 + + results.append( + { + "project": project.title, + "hours_tracked": float(hours_tracked), + "hours_budget": float(hours_budget), + "progress": min(progress, 1.0), + } + ) + return results diff --git a/tuttle/migrations/env.py b/tuttle/migrations/env.py index d758809..ed869b0 100644 --- a/tuttle/migrations/env.py +++ b/tuttle/migrations/env.py @@ -28,6 +28,7 @@ Invoice, InvoiceItem, TimelineItem, + FinancialGoal, ) diff --git a/tuttle/migrations/versions/2026_03_12_0002_add_financialgoal.py b/tuttle/migrations/versions/2026_03_12_0002_add_financialgoal.py new file mode 100644 index 0000000..ee70d43 --- /dev/null +++ b/tuttle/migrations/versions/2026_03_12_0002_add_financialgoal.py @@ -0,0 +1,36 @@ +"""add financialgoal + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-03-12 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "0002" +down_revision: Union[str, None] = "0001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "financialgoal", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("target_amount", sa.Numeric(12, 2), nullable=False), + sa.Column("target_date", sa.Date(), nullable=False), + sa.Column( + "is_reached", sa.Boolean(), nullable=False, server_default=sa.text("0") + ), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("financialgoal") diff --git a/tuttle/model.py b/tuttle/model.py index 5143e65..bf53b24 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -722,3 +722,23 @@ class TimelineItem(SQLModel, table=True): ) ) content: str + + +class FinancialGoal(SQLModel, table=True): + """A financial target the freelancer wants to reach.""" + + id: Optional[int] = Field(default=None, primary_key=True) + title: str = Field( + description="Short description of the goal, e.g. 'Yearly revenue'." + ) + target_amount: Decimal = Field( + description="Target amount in the user's currency.", + sa_column=sqlalchemy.Column(sqlalchemy.Numeric(12, 2), nullable=False), + ) + target_date: datetime.date = Field( + description="Date by which the goal should be reached.", + ) + is_reached: bool = Field( + default=False, + description="Whether the goal has been reached.", + ) diff --git a/tuttle_tests/test_dashboard.py b/tuttle_tests/test_dashboard.py new file mode 100644 index 0000000..1fdb3e5 --- /dev/null +++ b/tuttle_tests/test_dashboard.py @@ -0,0 +1,309 @@ +"""Tests for tuttle.forecasting and tuttle.kpi modules.""" + +import datetime +from decimal import Decimal + +import pytest + +from tuttle.model import ( + Address, + BankAccount, + Client, + Contact, + Contract, + Invoice, + InvoiceItem, + Project, + Timesheet, + TimeTrackingItem, + User, + FinancialGoal, +) +from tuttle.time import Cycle, TimeUnit +from tuttle.forecasting import ( + monthly_revenue_from_contracts, + revenue_history, + revenue_curve, +) +from tuttle.kpi import compute_kpis, monthly_revenue_breakdown, project_budget_status + + +# ── Fixtures ────────────────────────────────────────────────── + + +@pytest.fixture +def contact(): + return Contact( + first_name="Test", + last_name="Client", + email="test@example.com", + address=Address( + street="Test St", + number="1", + postal_code="12345", + city="Berlin", + country="Germany", + ), + ) + + +@pytest.fixture +def client(contact): + return Client(name="Test Corp", invoicing_contact=contact) + + +@pytest.fixture +def active_contract(client): + today = datetime.date.today() + return Contract( + title="Active Contract", + client=client, + signature_date=today - datetime.timedelta(days=60), + start_date=today - datetime.timedelta(days=30), + end_date=today + datetime.timedelta(days=180), + rate=Decimal("100.00"), + currency="EUR", + unit=TimeUnit.hour, + units_per_workday=8, + volume=500, + term_of_payment=14, + billing_cycle=Cycle.monthly, + ) + + +@pytest.fixture +def project(active_contract): + today = datetime.date.today() + return Project( + title="Test Project", + tag="#TestProject", + description="A test project", + start_date=today - datetime.timedelta(days=30), + end_date=today + datetime.timedelta(days=180), + contract=active_contract, + ) + + +@pytest.fixture +def timesheet_with_items(project): + today = datetime.date.today() + ts = Timesheet( + title="Test Timesheet", + date=today, + period_start=today - datetime.timedelta(days=30), + period_end=today, + project=project, + ) + for i in range(5): + item = TimeTrackingItem( + timesheet=ts, + begin=datetime.datetime( + today.year, today.month, max(today.day - 5 + i, 1), 9, 0 + ), + end=datetime.datetime( + today.year, today.month, max(today.day - 5 + i, 1), 17, 0 + ), + duration=datetime.timedelta(hours=8), + title=f"Work day {i+1}", + tag="#TestProject", + ) + ts.items.append(item) + return ts + + +@pytest.fixture +def paid_invoice(active_contract, project, timesheet_with_items): + today = datetime.date.today() + inv = Invoice( + number="2026-001", + date=today - datetime.timedelta(days=15), + contract=active_contract, + project=project, + sent=True, + paid=True, + rendered=True, + ) + inv.items.append( + InvoiceItem( + invoice=inv, + start_date=today - datetime.timedelta(days=30), + end_date=today, + quantity=40, + unit="hour", + unit_price=Decimal("100.00"), + description="Development work", + VAT_rate=Decimal("0.19"), + ) + ) + timesheet_with_items.invoice = inv + return inv + + +@pytest.fixture +def unpaid_invoice(active_contract, project): + today = datetime.date.today() + inv = Invoice( + number="2026-002", + date=today - datetime.timedelta(days=5), + contract=active_contract, + project=project, + sent=True, + paid=False, + rendered=True, + ) + inv.items.append( + InvoiceItem( + invoice=inv, + start_date=today - datetime.timedelta(days=10), + end_date=today - datetime.timedelta(days=5), + quantity=20, + unit="hour", + unit_price=Decimal("100.00"), + description="More work", + VAT_rate=Decimal("0.19"), + ) + ) + return inv + + +# ── Forecasting Tests ───────────────────────────────────────── + + +class TestMonthlyRevenueFromContracts: + def test_empty_contracts(self): + today = datetime.date.today() + result = monthly_revenue_from_contracts( + [], today, today + datetime.timedelta(days=60) + ) + assert result.empty + + def test_active_contract_produces_rows(self, active_contract, project): + # project must be attached for the function to pick up the title + active_contract.projects = [project] + today = datetime.date.today() + result = monthly_revenue_from_contracts( + [active_contract], + today, + today + datetime.timedelta(days=90), + ) + assert not result.empty + assert "month" in result.columns + assert "revenue" in result.columns + assert all(result["revenue"] > 0) + + def test_completed_contract_excluded(self, active_contract, project): + active_contract.is_completed = True + active_contract.projects = [project] + today = datetime.date.today() + result = monthly_revenue_from_contracts( + [active_contract], + today, + today + datetime.timedelta(days=90), + ) + assert result.empty + + def test_future_contract_excluded_before_start(self, active_contract, project): + today = datetime.date.today() + active_contract.start_date = today + datetime.timedelta(days=100) + active_contract.end_date = today + datetime.timedelta(days=200) + active_contract.projects = [project] + result = monthly_revenue_from_contracts( + [active_contract], + today, + today + datetime.timedelta(days=30), + ) + assert result.empty + + +class TestRevenueHistory: + def test_empty_invoices(self): + result = revenue_history([]) + assert result.empty + + def test_cancelled_invoices_excluded(self, paid_invoice): + paid_invoice.cancelled = True + result = revenue_history([paid_invoice]) + assert result.empty + + def test_paid_invoice_included(self, paid_invoice): + result = revenue_history([paid_invoice]) + assert not result.empty + assert result["revenue"].sum() > 0 + + +class TestRevenueCurve: + def test_combined_history_and_forecast( + self, paid_invoice, active_contract, project + ): + active_contract.projects = [project] + result = revenue_curve([paid_invoice], [active_contract], forecast_months=3) + assert not result.empty + assert "is_forecast" in result.columns + assert "cumulative_revenue" in result.columns + + def test_empty_data(self): + result = revenue_curve([], [], forecast_months=3) + assert result.empty or len(result) == 0 + + +# ── KPI Tests ───────────────────────────────────────────────── + + +class TestComputeKPIs: + def test_with_paid_invoice(self, paid_invoice, active_contract, project): + kpis = compute_kpis([paid_invoice], [active_contract], [project]) + assert kpis.total_revenue > 0 + assert kpis.outstanding_amount == 0 + assert kpis.active_contracts == 1 + assert kpis.active_projects == 1 + assert kpis.unpaid_invoices == 0 + + def test_with_unpaid_invoice(self, unpaid_invoice, active_contract, project): + kpis = compute_kpis([unpaid_invoice], [active_contract], [project]) + assert kpis.total_revenue == 0 + assert kpis.outstanding_amount > 0 + assert kpis.unpaid_invoices == 1 + + def test_effective_hourly_rate(self, paid_invoice, active_contract, project): + kpis = compute_kpis([paid_invoice], [active_contract], [project]) + if kpis.effective_hourly_rate is not None: + assert kpis.effective_hourly_rate > 0 + + def test_empty_data(self): + kpis = compute_kpis([], [], []) + assert kpis.total_revenue == 0 + assert kpis.active_projects == 0 + assert kpis.active_contracts == 0 + + +class TestMonthlyRevenueBreakdown: + def test_empty_invoices(self): + result = monthly_revenue_breakdown([]) + assert isinstance(result, list) + assert len(result) > 0 # should still have month buckets + + def test_with_invoices(self, paid_invoice): + result = monthly_revenue_breakdown([paid_invoice], n_months=3) + assert isinstance(result, list) + total = sum(float(m["revenue"]) for m in result) + assert total > 0 + + +class TestProjectBudgetStatus: + def test_project_without_volume(self, project): + project.contract.volume = None + result = project_budget_status([project]) + assert result == [] + + def test_project_with_volume_and_timesheets(self, project, timesheet_with_items): + project.contract.volume = 100 + project.timesheets = [timesheet_with_items] + result = project_budget_status([project]) + assert len(result) == 1 + assert result[0]["project"] == "Test Project" + assert result[0]["hours_tracked"] > 0 + assert 0 <= result[0]["progress"] <= 1.0 + + def test_empty_projects(self): + result = project_budget_status([]) + assert result == []