Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
407 changes: 273 additions & 134 deletions app/locale/de/LC_MESSAGES/django.po

Large diffs are not rendered by default.

472 changes: 307 additions & 165 deletions app/locale/en/LC_MESSAGES/django.po

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions app/my_practice/utils/analytics_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ def _get_monthly_aggregation(
if end_date is None:
end_date = date.today()

# Only show complete months — exclude the current partial month so the last
# data point isn't anomalously low (same guard as get_capacity_trends).
from datetime import timedelta

first_of_current_month = date.today().replace(day=1)
if end_date >= first_of_current_month:
end_date = first_of_current_month - timedelta(days=1)

if start_date is None:
start_date = date(start_year, 1, 1)

Expand Down
40 changes: 21 additions & 19 deletions app/my_practice/utils/capacity_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
]


def _build_holiday_set(start_year: int, end_year: int) -> set[date]:
"""Build the set of Berlin public holidays covering all years in [start_year, end_year]."""
holidays: set[date] = set()
for yr in range(start_year, end_year + 1):
holidays |= berlin_public_holidays(yr)
return holidays


def get_weekly_capacity_for_date(target_date: date) -> float:
"""
Get the weekly capacity (hours) for a specific date.
Expand Down Expand Up @@ -86,9 +94,7 @@ def calculate_period_capacity(
effective_end_date = end_date

# Build holiday set once for the effective date range
_holidays: set[date] = set()
for yr in range(start_date.year, effective_end_date.year + 1):
_holidays |= berlin_public_holidays(yr)
_holidays = _build_holiday_set(start_date.year, effective_end_date.year)

# Working days in effective period, excluding public holidays
working_days = DateRangeHelper.count_working_days(start_date, effective_end_date, _holidays)
Expand Down Expand Up @@ -265,6 +271,12 @@ def get_capacity_trends(start_year=2020, end_date=None, start_date=None, practic
if end_date is None:
end_date = date.today()

# Only show complete months — exclude the current partial month so the last
# data point isn't anomalously low (e.g. 2 working days = 8h capacity).
first_of_current_month = date.today().replace(day=1)
if end_date >= first_of_current_month:
end_date = first_of_current_month - timedelta(days=1)

if start_date is None:
start_date = date(start_year, 1, 1)

Expand Down Expand Up @@ -297,9 +309,7 @@ def get_capacity_trends(start_year=2020, end_date=None, start_date=None, practic
)

# Pre-build holiday set covering all years in the range (used in the loop below)
_holidays: set[date] = set()
for yr in range(start_date.year, end_date.year + 1):
_holidays |= berlin_public_holidays(yr)
_holidays = _build_holiday_set(start_date.year, end_date.year)

# Build capacity data iterating through months (no DB queries in loop)
capacity_data = []
Expand All @@ -308,15 +318,7 @@ def get_capacity_trends(start_year=2020, end_date=None, start_date=None, practic
while current_date <= end_date:
# Calculate month boundaries
month_start = current_date.replace(day=1)
if current_date.month == 12:
next_month = current_date.replace(year=current_date.year + 1, month=1, day=1)
else:
next_month = current_date.replace(month=current_date.month + 1, day=1)
month_end = next_month - timedelta(days=1)

# Don't go beyond end_date
if month_end > end_date:
month_end = end_date
month_end = DateRangeHelper.get_last_of_month(current_date)

# Working days in the month, excluding public holidays
working_days = DateRangeHelper.count_working_days(month_start, month_end, _holidays)
Expand All @@ -333,10 +335,10 @@ def get_capacity_trends(start_year=2020, end_date=None, start_date=None, practic

available_working_days = max(0, working_days - timeoff_days)

# Calculate capacity based on period configuration
hours_per_week = get_weekly_capacity_for_date(month_start)
available_weeks = available_working_days / 5
usable_capacity = available_weeks * hours_per_week
# Delegate to the same weighted formula used by calculate_period_capacity
usable_capacity = _calculate_weighted_capacity(
month_start, month_end, available_working_days, _holidays
)

# Get booked hours from cached data
month_key = format_month_key(current_date)
Expand Down
67 changes: 52 additions & 15 deletions app/my_practice/utils/practice_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from dateutil.relativedelta import relativedelta
from django.db.models import FloatField, Sum
from django.db.models.functions import Cast
from django.utils.translation import gettext as _, ngettext

from ..models import Client, Invoice
from ..models.session import Session
Expand Down Expand Up @@ -224,7 +225,12 @@ def _get_timeoff_data(self):
"total_weeks": result["total_weeks"],
"workdays": result["total_workdays"],
"entries": result["entries"],
"capacity_impact": f"{result['total_workdays']} Arbeitstage",
"capacity_impact": ngettext(
"%(n)s working day",
"%(n)s working days",
result["total_workdays"],
)
% {"n": result["total_workdays"]},
}

def _calculate_capacity(self, period_sessions):
Expand Down Expand Up @@ -259,14 +265,23 @@ def generate_insights(self, analysis) -> list[str]:

def _period_insights(self, analysis) -> list[str]:
insights = [
f"📅 Analyzing {analysis['period']['label']} ({analysis['period']['days']} days)"
_("📅 Analyzing %(label)s (%(days)s days)")
% {
"label": analysis["period"]["label"],
"days": analysis["period"]["days"],
}
]
active_count = analysis["clients"]["active_in_period"]
total_count = analysis["clients"]["total"]
if active_count > 0:
active_pct = (active_count / total_count * 100) if total_count > 0 else 0
insights.append(
f"👥 {active_count} of {total_count} clients active ({active_pct:.0f}%)"
_("👥 %(active)s of %(total)s clients active (%(pct)s%%)")
% {
"active": active_count,
"total": total_count,
"pct": f"{active_pct:.0f}",
}
)
return insights

Expand All @@ -281,51 +296,73 @@ def _client_insights(self, clients) -> list[str]:
concentration = (top_3 / total_sessions) * 100
if concentration > 60:
insights.append(
f"⚠️ High concentration: Top 3 clients = {concentration:.0f}% of sessions"
_("⚠️ High concentration: Top 3 clients = %(pct)s%% of sessions")
% {"pct": f"{concentration:.0f}"}
)
elif concentration > 40:
insights.append(
f"📊 Top 3 clients account for {concentration:.0f}% of sessions"
_("📊 Top 3 clients account for %(pct)s%% of sessions")
% {"pct": f"{concentration:.0f}"}
)

# Average per active client
active = [c for c in clients if c["classification"] in ["established", "probatoric"]]
if active:
avg = sum(c["sessions_in_period"] for c in active) / len(active)
insights.append(f"📈 Average: {avg:.1f}h per active client")
insights.append(_("📈 Average: %(avg)sh per active client") % {"avg": f"{avg:.1f}"})

# Probatoric
probatoric = [c for c in clients if c["classification"] == "probatoric"]
if probatoric:
ph = sum(c["sessions_in_period"] for c in probatoric)
s = "s" if len(probatoric) != 1 else ""
insights.append(f"🌱 {len(probatoric)} new probatoric client{s} ({ph:.1f}h)")
insights.append(
ngettext(
"🌱 %(n)s new probatoric client (%(h)sh)",
"🌱 %(n)s new probatoric clients (%(h)sh)",
len(probatoric),
)
% {"n": len(probatoric), "h": f"{ph:.1f}"}
)

# Dormant
dormant = [c for c in clients if c["classification"] == "dormant"]
if len(dormant) > 5:
insights.append(f"💤 {len(dormant)} dormant clients (no activity this period)")
insights.append(
_("💤 %(n)s dormant clients (no activity this period)") % {"n": len(dormant)}
)

return insights

def _capacity_insights(self, capacity) -> list[str]:
cap_pct = capacity["capacity_percentage"]
rem = capacity["remaining_hours"]
vals = {"pct": cap_pct, "rem": f"{rem:.0f}"}
if cap_pct < 30:
return [f"📉 Low utilization: Only {cap_pct}% capacity used ({rem:.0f}h available)"]
return [
_("📉 Low utilization: Only %(pct)s%% capacity used (%(rem)sh available)") % vals
]
if cap_pct < 60:
return [f"📊 Moderate utilization: {cap_pct}% capacity used ({rem:.0f}h available)"]
return [
_("📊 Moderate utilization: %(pct)s%% capacity used (%(rem)sh available)") % vals
]
if cap_pct < 80:
return [f"✅ Good utilization: {cap_pct}% capacity used ({rem:.0f}h available)"]
return [_("✅ Good utilization: %(pct)s%% capacity used (%(rem)sh available)") % vals]
if cap_pct < 100:
return [f"⚠️ High utilization: {cap_pct}% capacity used (only {rem:.0f}h remaining)"]
return [f"🔴 At/over capacity: {cap_pct}% utilized"]
return [
_("⚠️ High utilization: %(pct)s%% capacity used (only %(rem)sh remaining)") % vals
]
return [_("🔴 At/over capacity: %(pct)s%% utilized") % vals]

def _revenue_insights(self, clients) -> list[str]:
unbilled = [c for c in clients if c["invoices_count"] == 0 and c["sessions_in_period"] > 0]
if unbilled:
return [
f"💰 Revenue opportunity: {len(unbilled)} client(s) with sessions but no invoices"
ngettext(
"💰 Revenue opportunity: %(n)s client with sessions but no invoices",
"💰 Revenue opportunity: %(n)s clients with sessions but no invoices",
len(unbilled),
)
% {"n": len(unbilled)}
]
return []

Expand Down
11 changes: 0 additions & 11 deletions app/static/js/chart_utils.test.extended.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

// Load the functions from refactored chart_math module
const {
calculateYearLabels,
parseMonthString,
findFirstNonZeroIndex,
calculatePoints,
Expand Down Expand Up @@ -34,16 +33,6 @@ function assertEquals(actual, expected, message) {
console.log('\n📊 Additional Chart Utils Tests\n');

// Edge case tests
test('calculateYearLabels - empty data returns empty array', () => {
const labels = calculateYearLabels(0, 2020, 12);
assertEquals(labels.length, 0, 'Should have 0 labels for 0 months');
});

test('calculateYearLabels - single month', () => {
const labels = calculateYearLabels(1, 2020, 12);
assertEquals(labels, [{ index: 0, year: 2020 }], 'Should have single label');
});

test('parseMonthString - handles all month names', () => {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
months.forEach((month, idx) => {
Expand Down
16 changes: 0 additions & 16 deletions app/static/js/chart_utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ if (typeof module === 'undefined') {

// Load the functions from refactored chart_math module
const {
calculateYearLabels,
parseMonthString,
findFirstNonZeroIndex,
calculatePoints,
Expand Down Expand Up @@ -45,21 +44,6 @@ function assertNotNull(value, message) {
// Tests
console.log('\n📊 Running Chart Utils Tests\n');

test('calculateYearLabels - basic case with 24 months', () => {
// 24 data points = indices 0-23, so labels at index 0 (2020) and 12 (2021)
const labels = calculateYearLabels(24, 2020, 12);
assertEquals(labels.length, 2, 'Should have 2 labels');
assertEquals(labels[0], { index: 0, year: 2020 }, 'First label');
assertEquals(labels[1], { index: 12, year: 2021 }, 'Second label');
});

test('calculateYearLabels - 63 months starting 2020', () => {
const labels = calculateYearLabels(63, 2020, 12);
assertEquals(labels.length, 6, 'Should have 6 labels');
assertEquals(labels[0].year, 2020, 'First year');
assertEquals(labels[5].year, 2025, 'Last year');
});

test('parseMonthString - YYYY-MM format', () => {
const result = parseMonthString('2020-10');
assertNotNull(result, 'Should parse successfully');
Expand Down
29 changes: 2 additions & 27 deletions app/static/js/charts/chart_bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,42 +77,17 @@ function drawSimpleBarChart(ctx, padding, chartWidth, chartHeight, height, label
ctx.fillText(text, textX, textY);
}

// Draw x-axis label directly
ctx.fillStyle = getCSSVariable('--text-primary');
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(String(labels[index]), x + barWidth / 2, height - padding.bottom + 25);
// Draw x-axis label
drawYearLabel(ctx, labels[index], x + barWidth / 2, height, padding);
});

return positions;
}

/**
* Setup basic bar chart infrastructure
*/
function setupBarChart(canvasOrId, options = {}) {
// Handle both canvas element and ID
const canvas = typeof canvasOrId === 'string' ? document.getElementById(canvasOrId) : canvasOrId;
if (!canvas) return null;

const padding = options.padding || { top: 35, right: 20, bottom: 60, left: 60 };
const setup = setupCanvas(canvas, padding);
if (!setup) return null;

const { ctx, chartWidth, chartHeight } = setup;

// Draw grid, axes
drawGrid(ctx, setup.padding, chartWidth, chartHeight);
drawAxes(ctx, setup.padding, chartWidth, chartHeight);

return setup;
}

// Export functions
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
drawGroupedBars,
drawSimpleBarChart,
setupBarChart
};
}
5 changes: 3 additions & 2 deletions app/static/js/charts/chart_builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,9 @@ class ChartBuilder {
// Draw year separators and labels if needed
if (this.config.options.showYearSeparators) {
const startYear = this.config.options.startYear || 2020;
drawYearSeparators(ctx, padding, chartHeight, chartWidth, this.config.data.length, startYear);
drawYearLabels(ctx, canvas.height, points, startYear);
const startMonth = this.config.options.startMonth || 1;
drawYearSeparators(ctx, padding, chartHeight, chartWidth, this.config.data.length, startYear, undefined, startMonth);
drawYearLabels(ctx, canvas.height, points, startYear, startMonth);
}

// Draw trendline if requested
Expand Down
16 changes: 10 additions & 6 deletions app/static/js/charts/chart_helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
/**
* Display empty state message on canvas
* @param {HTMLCanvasElement} canvas - Canvas element
* @param {string} message - Message to display
* @param {string} [message] - Message to display; defaults to CHART_MESSAGES.noData if set
*/
function showChartEmptyState(canvas, message = 'Keine Daten vorhanden') {
function showChartEmptyState(canvas, message) {
if (message === undefined) {
message = (typeof CHART_MESSAGES !== 'undefined') ? CHART_MESSAGES.noData : 'Keine Daten vorhanden';
}
// Size the canvas before drawing so coordinates match the displayed area.
setupCanvas(canvas);
const ctx = canvas.getContext('2d');
Expand Down Expand Up @@ -80,12 +83,13 @@ function validateChartData(data, options = {}) {
* @returns {string} User-friendly message
*/
function getValidationMessage(reason) {
const cm = (typeof CHART_MESSAGES !== 'undefined') ? CHART_MESSAGES : null;
const messages = {
'empty': 'Keine Daten vorhanden',
'all_zeros': 'Keine Daten im ausgewählten Zeitraum',
'invalid_max': 'Keine gültigen Daten vorhanden'
'empty': cm ? cm.noData : 'Keine Daten vorhanden',
'all_zeros': cm ? cm.allZero : 'Keine Daten im ausgewählten Zeitraum',
'invalid_max': cm ? cm.invalidMax : 'Keine gültigen Daten vorhanden',
};
return messages[reason] || 'Keine Daten vorhanden';
return messages[reason] || (cm ? cm.noData : 'Keine Daten vorhanden');
}

// Export functions
Expand Down
Loading