@@ -342,19 +342,13 @@ class IncomeView(CRMMixin, TemplateView): # type: ignore[misc]
342342 CHART_PADDING = 60
343343 MIN_CHART_VALUE = Decimal (1 )
344344
345- # Category colors shared across all charts
345+ # Category colors shared across all charts (keyed by category label)
346346 CATEGORY_COLORS = {
347347 "Hosting" : "#417690" ,
348348 "Support" : "#79aec8" ,
349349 "Development / Consultations" : "#5b80b2" ,
350350 "Donation" : "#9fc5e8" ,
351351 }
352- CATEGORY_COLORS_BY_VALUE = {
353- InvoiceCategory .HOSTING .value : "#417690" ,
354- InvoiceCategory .SUPPORT .value : "#79aec8" ,
355- InvoiceCategory .DEVEL .value : "#5b80b2" ,
356- InvoiceCategory .DONATE .value : "#9fc5e8" ,
357- }
358352
359353 def get_year (self ) -> int :
360354 """Get the year from URL kwargs or default to current year."""
@@ -469,9 +463,13 @@ def generate_svg_pie_chart(self, data: dict[str, Decimal]) -> str: # noqa: PLR0
469463 return "" .join (svg_parts )
470464
471465 def generate_svg_stacked_bar_chart ( # noqa: PLR0914
472- self , monthly_data : dict , invoices : list
466+ self , monthly_data : dict , invoices : list , year : int , month : int | None = None
473467 ) -> str :
474- """Generate a stacked bar chart showing monthly totals by category."""
468+ """Generate a stacked bar chart showing totals by category.
469+
470+ For yearly view (month=None): shows 12 monthly bars
471+ For monthly view (month=int): shows daily bars for that month
472+ """
475473 if not monthly_data :
476474 return ""
477475
@@ -484,6 +482,20 @@ def generate_svg_stacked_bar_chart( # noqa: PLR0914
484482 # Pre-calculate invoice totals in EUR
485483 invoice_totals = {inv .pk : inv .total_amount_no_vat for inv in invoices }
486484
485+ # Determine number of bars and labels based on view type
486+ if month :
487+ # Monthly view: show daily bars
488+ num_bars = calendar .monthrange (year , month )[1 ]
489+ bar_labels = [str (d ) for d in range (1 , num_bars + 1 )]
490+ filter_func = lambda inv , idx : inv .issue_date .day == idx + 1
491+ label_prefix = "Day"
492+ else :
493+ # Yearly view: show monthly bars
494+ num_bars = 12
495+ bar_labels = [f"{ m :02d} " for m in range (1 , 13 )]
496+ filter_func = lambda inv , idx : inv .issue_date .month == idx + 1
497+ label_prefix = ""
498+
487499 # Get max value for scaling
488500 max_value = max (monthly_data .values ()) if monthly_data .values () else Decimal (1 )
489501 if max_value <= 0 :
@@ -494,18 +506,17 @@ def generate_svg_stacked_bar_chart( # noqa: PLR0914
494506 ]
495507
496508 # Calculate bar properties
497- num_bars = 12
498509 bar_spacing = chart_width / (num_bars * 1.5 )
499510 bar_width = bar_spacing * 0.8
500511
501- # Draw each month
502- for month_idx in range (1 , 13 ):
503- month_key = f" { month_idx :02d } "
504- x : float = padding + bar_spacing * (month_idx - 0.5 )
512+ # Draw each bar
513+ for idx in range (num_bars ):
514+ bar_label = bar_labels [ idx ]
515+ x : float = padding + bar_spacing * (idx + 0.5 )
505516
506- # Get invoices for this month by category
507- month_invoices = [
508- inv for inv in invoices if inv . issue_date . month == month_idx
517+ # Get invoices for this time period by category
518+ period_invoices = [
519+ inv for inv in invoices if filter_func ( inv , idx )
509520 ]
510521
511522 # Stack bars by category
@@ -514,7 +525,7 @@ def generate_svg_stacked_bar_chart( # noqa: PLR0914
514525 category_total = sum (
515526 (
516527 invoice_totals [inv .pk ]
517- for inv in month_invoices
528+ for inv in period_invoices
518529 if inv .category == category .value
519530 ),
520531 start = Decimal (0 ),
@@ -524,84 +535,30 @@ def generate_svg_stacked_bar_chart( # noqa: PLR0914
524535 bar_height = float (category_total / max_value * chart_height )
525536 y = y_offset - bar_height
526537
538+ title_label = f"{ label_prefix } { bar_label } " if label_prefix else bar_label
527539 svg_parts .append (
528540 f'<rect x="{ x } " y="{ y } " width="{ bar_width } " height="{ bar_height } " '
529- f'fill="{ self .CATEGORY_COLORS_BY_VALUE .get (category .value , "#999" )} " stroke="white" stroke-width="1">'
530- f"<title>{ category .label } - { month_key } : €{ category_total :,.0f} </title>"
541+ f'fill="{ self .CATEGORY_COLORS .get (category .label , "#999" )} " stroke="white" stroke-width="1">'
542+ f"<title>{ category .label } - { title_label } : €{ category_total :,.0f} </title>"
531543 f"</rect>"
532544 )
533545 y_offset = y
534546
535- # Month label
547+ # Bar label
536548 label_x = x + bar_width / 2
537549 label_y = height - padding + 15
550+ font_size = "9" if month else "10" # Smaller font for daily view (more bars)
538551 svg_parts .append (
539552 f'<text x="{ label_x } " y="{ label_y } " text-anchor="middle" '
540- f'font-size="10" fill="#666">{ month_key } </text>'
541- )
542-
543- svg_parts .append ("</svg>" )
544- return "" .join (svg_parts )
545-
546- def generate_svg_daily_chart ( # noqa: PLR0914
547- self , year : int , month : int , invoices : list
548- ) -> str :
549- """Generate a daily bar chart for a specific month."""
550- width = self .CHART_WIDTH
551- height = 300
552- padding = 40
553- chart_width = width - 2 * padding
554- chart_height = height - 2 * padding
555-
556- # Get number of days in month
557- num_days = calendar .monthrange (year , month )[1 ]
558-
559- # Pre-calculate invoice totals in EUR
560- invoice_totals = {inv .pk : inv .total_amount_no_vat for inv in invoices }
561-
562- # Calculate daily totals
563- daily_totals = {}
564- for day in range (1 , num_days + 1 ):
565- daily_invoices = [inv for inv in invoices if inv .issue_date .day == day ]
566- daily_totals [day ] = sum (
567- (invoice_totals [inv .pk ] for inv in daily_invoices ),
568- start = Decimal (0 ),
569- )
570-
571- max_value = max (daily_totals .values ()) if daily_totals .values () else Decimal (1 )
572- if max_value <= 0 :
573- max_value = self .MIN_CHART_VALUE
574-
575- svg_parts = [
576- f'<svg width="{ width } " height="{ height } " xmlns="http://www.w3.org/2000/svg" class="daily-chart">' ,
577- ]
578-
579- # Draw bars
580- bar_width = chart_width / num_days * 0.8
581- for day , value in daily_totals .items ():
582- x : float = padding + (day - 1 ) * (chart_width / num_days )
583- bar_height = float (value / max_value * chart_height ) if value > 0 else 0
584- y = height - padding - bar_height
585-
586- svg_parts .append (
587- f'<rect x="{ x } " y="{ y } " width="{ bar_width } " height="{ bar_height } " '
588- f'fill="#417690">'
589- f"<title>Day { day } : €{ value :,.0f} </title>"
590- f"</rect>"
591- )
592-
593- # Show all day labels
594- label_x = x + bar_width / 2
595- label_y = height - padding + 12
596- svg_parts .append (
597- f'<text x="{ label_x } " y="{ label_y } " text-anchor="middle" '
598- f'font-size="9" fill="#666">{ day } </text>'
553+ f'font-size="{ font_size } " fill="#666">{ bar_label } </text>'
599554 )
600555
601556 svg_parts .append ("</svg>" )
602557 return "" .join (svg_parts )
603558
604- def get_income_data (self , year : int , month : int | None = None ):
559+ def get_income_data (
560+ self , year : int , month : int | None = None
561+ ) -> dict [str , Decimal ]:
605562 """Get income data aggregated by category."""
606563 # Build query with proper database-level filtering
607564 query = Invoice .objects .filter (kind = InvoiceKind .INVOICE , issue_date__year = year )
@@ -629,7 +586,7 @@ def get_income_data(self, year: int, month: int | None = None):
629586
630587 return category_data
631588
632- def get_monthly_data (self , year : int ):
589+ def get_monthly_data (self , year : int ) -> tuple [ dict [ str , Decimal ], list ] :
633590 """Get monthly income data for the year."""
634591 # Fetch all invoices for the year at once to avoid 12 separate queries
635592 invoices = list (
@@ -656,7 +613,38 @@ def get_monthly_data(self, year: int):
656613
657614 return monthly_totals , invoices
658615
659- def get_monthly_category_data (self , year : int ):
616+ def get_daily_data (self , year : int , month : int ) -> tuple [dict [str , Decimal ], list ]:
617+ """Get daily income data for a specific month."""
618+ invoices = list (
619+ Invoice .objects .filter (
620+ kind = InvoiceKind .INVOICE , issue_date__year = year , issue_date__month = month
621+ ).prefetch_related ("invoiceitem_set" )
622+ )
623+
624+ # Pre-calculate totals in EUR
625+ invoice_totals = {inv .pk : inv .total_amount_no_vat for inv in invoices }
626+
627+ # Get number of days in month
628+ num_days = calendar .monthrange (year , month )[1 ]
629+
630+ # Group by day
631+ daily_totals = {}
632+ for day in range (1 , num_days + 1 ):
633+ total = sum (
634+ (
635+ invoice_totals [inv .pk ]
636+ for inv in invoices
637+ if inv .issue_date .day == day
638+ ),
639+ start = Decimal (0 ),
640+ )
641+ daily_totals [str (day )] = total
642+
643+ return daily_totals , invoices
644+
645+ def get_monthly_category_data (
646+ self , year : int
647+ ) -> tuple [dict [str , dict [str , Decimal ]], list ]:
660648 """Get monthly income data split by category for stacked chart."""
661649 invoices = list (
662650 Invoice .objects .filter (
@@ -708,25 +696,17 @@ def get_context_data(self, **kwargs):
708696 context ["pie_chart_svg" ] = self .generate_svg_pie_chart (income_data )
709697
710698 if month :
711- # For monthly view, show daily chart
712- # Get all invoices for the month
713- month_invoices = list (
714- Invoice .objects .filter (
715- kind = InvoiceKind .INVOICE ,
716- issue_date__year = year ,
717- issue_date__month = month ,
718- ).prefetch_related ("invoiceitem_set" )
719- )
720-
721- context ["daily_chart_svg" ] = self .generate_svg_daily_chart (
722- year , month , month_invoices
699+ # For monthly view, show daily stacked chart
700+ daily_data , daily_invoices = self .get_daily_data (year , month )
701+ context ["daily_chart_svg" ] = self .generate_svg_stacked_bar_chart (
702+ daily_data , daily_invoices , year , month
723703 )
724704 context ["is_monthly" ] = True
725705 else :
726- # For yearly view, show stacked monthly chart
706+ # For yearly view, show monthly stacked chart
727707 monthly_data , invoices = self .get_monthly_data (year )
728708 context ["chart_svg" ] = self .generate_svg_stacked_bar_chart (
729- monthly_data , invoices
709+ monthly_data , invoices , year
730710 )
731711 context ["monthly_data" ] = monthly_data
732712 context ["monthly_category_data" ], _ = self .get_monthly_category_data (year )
0 commit comments