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
780 changes: 407 additions & 373 deletions app/locale/de/LC_MESSAGES/django.po

Large diffs are not rendered by default.

772 changes: 403 additions & 369 deletions app/locale/en/LC_MESSAGES/django.po

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion app/my_practice/tests/test_css_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@
".timeoff-table__",
".progress-bar-fill",
".chart-bar",
".monthly-bar",
".summary-table",
".summary-cards",
".tax-summary-container",
Expand Down
86 changes: 86 additions & 0 deletions app/my_practice/tests/test_inquiry.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
ClientInquiry,
InquirySource,
InquiryStatus,
MarketingPeriod,
Practice,
UserPractice,
)
from my_practice.views.inquiry_views import _monthly_chart, _open_inquiry_ageing


def _make_user_and_practice(username="testuser"):
Expand Down Expand Up @@ -304,3 +306,87 @@ def test_convert_scoped_to_practice(self):
},
)
self.assertEqual(resp.status_code, 404)


class InquiryChartTestCase(TestCase):
"""The monthly chart data and the open-inquiry ageing list on the inquiry page."""

def setUp(self):
_, self.practice = _make_user_and_practice()
self.today = date(2026, 10, 21)

def _qs(self):
return ClientInquiry.objects.filter(practice=self.practice)

def test_monthly_chart_lists_every_month_including_empty_ones(self):
_make_inquiry(self.practice, inquiry_date=date(2026, 4, 2))
_make_inquiry(self.practice, inquiry_date=date(2026, 4, 20))
_make_inquiry(self.practice, inquiry_date=date(2025, 10, 31)) # before the window
chart = _monthly_chart(self._qs(), self.practice, self.today)
self.assertEqual(len(chart["labels"]), 12)
self.assertEqual(chart["labels"][0], "11/25")
self.assertEqual(chart["labels"][-1], "10/26")
self.assertEqual(chart["counts"][chart["labels"].index("04/26")], 2)
self.assertEqual(sum(chart["counts"]), 2)

def test_monthly_chart_places_marketing_periods_in_bar_slots(self):
MarketingPeriod.objects.create(
practice=self.practice,
start_date=date(2026, 3, 1),
end_date=date(2026, 6, 30),
description="Ads",
)
MarketingPeriod.objects.create(
practice=self.practice, start_date=date(2025, 9, 8), description="Directory"
)
MarketingPeriod.objects.create(
practice=self.practice,
start_date=date(2024, 1, 1),
end_date=date(2024, 12, 31),
description="Long gone",
)
lanes = _monthly_chart(self._qs(), self.practice, self.today)["lanes"]
self.assertEqual([lane["label"] for lane in lanes], ["Directory", "Ads"])
# Started before the window and still running: spans the whole axis.
self.assertEqual((lanes[0]["from"], lanes[0]["to"]), (-0.5, 11.5))
# March is slot 4; a whole-month period fills March through June exactly.
self.assertEqual((lanes[1]["from"], lanes[1]["to"]), (3.5, 7.5))

def test_monthly_chart_scoped_to_practice(self):
_, other = _make_user_and_practice(username="other")
_make_inquiry(other, inquiry_date=date(2026, 5, 1))
MarketingPeriod.objects.create(practice=other, start_date=date(2026, 1, 1), description="X")
chart = _monthly_chart(self._qs(), self.practice, self.today)
self.assertEqual(sum(chart["counts"]), 0)
self.assertEqual(chart["lanes"], [])

def test_ageing_uses_latest_step_and_sorts_longest_first(self):
waiting = _make_inquiry(
self.practice,
status=InquiryStatus.CONTACTED,
inquiry_date=date(2026, 8, 1),
contacted_date=date(2026, 8, 3),
)
booked = _make_inquiry(
self.practice,
status=InquiryStatus.INTRO_MEETING,
inquiry_date=date(2026, 10, 1),
intro_date=date(2026, 10, 28), # still ahead
)
_make_inquiry(self.practice, status=InquiryStatus.CONVERTED)
rows = _open_inquiry_ageing(self._qs(), self.today)
self.assertEqual([row["inquiry"] for row in rows], [waiting, booked])
self.assertEqual(rows[0]["days"], 79)
self.assertEqual(rows[0]["step_date"], date(2026, 8, 3))
self.assertTrue(rows[0]["stale"])
self.assertEqual(rows[0]["pct"], 100)
self.assertEqual(rows[1]["days"], 0)
self.assertFalse(rows[1]["stale"])

def test_list_page_renders_charts(self):
user = User.objects.get(username="testuser")
self.client.force_login(user)
_make_inquiry(self.practice, inquiry_date=date(2026, 9, 1))
resp = self.client.get(reverse("inquiry_list"))
self.assertContains(resp, 'id="inquiryMonthlyChart"')
self.assertContains(resp, "ageing-row")
118 changes: 96 additions & 22 deletions app/my_practice/views/inquiry_views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Views for client inquiry / lead tracking (P-031)."""

import calendar
from datetime import date, timedelta
from itertools import groupby
from typing import cast
Expand All @@ -10,6 +11,7 @@
Case,
Count,
IntegerField,
Q,
Value,
When,
)
Expand Down Expand Up @@ -62,9 +64,101 @@ def _sub(text: str) -> str:
return result


# Months shown in the monthly inquiry chart, ending with the current month.
_CHART_MONTHS = 12

# An open inquiry with no new step for this long counts as stale — the same
# threshold the grouped list below uses to fold rows away.
_STALE_DAYS = 30


def _month_offset(first_month: date, day: date) -> float:
"""Position of `day` on a month axis starting at `first_month`, in bar slots.

Slot i is centred on i, so its month starts at i - 0.5; the day's fraction
of its month places it inside the slot.
"""
months = (day.year - first_month.year) * 12 + day.month - first_month.month
days_in_month = calendar.monthrange(day.year, day.month)[1]
return months + (day.day - 1) / days_in_month - 0.5


def _monthly_chart(base_qs, practice, today: date) -> dict:
"""Inquiries per month for the last 12 months, plus the marketing periods as lanes.

Every month is listed, including empty ones — a gap in the bars is itself
the signal (e.g. volume dropping once a paid campaign ended).
"""
first_month = DateRangeHelper.add_months(date(today.year, today.month, 1), 1 - _CHART_MONTHS)
months = [DateRangeHelper.add_months(first_month, i) for i in range(_CHART_MONTHS)]
counts = {
row["month"]: row["count"]
for row in base_qs.filter(inquiry_date__gte=first_month)
.annotate(month=TruncMonth("inquiry_date"))
.values("month")
.annotate(count=Count("id"))
}

axis_end = _CHART_MONTHS - 0.5
lanes = []
periods = MarketingPeriod.objects.filter(practice=practice, start_date__lte=today).filter(
Q(end_date__isnull=True) | Q(end_date__gte=first_month)
)
for period in periods.order_by("start_date"):
start = max(_month_offset(first_month, period.start_date), -0.5)
if period.end_date is None:
end = axis_end
else:
end = min(_month_offset(first_month, period.end_date + timedelta(days=1)), axis_end)
lanes.append({"label": period.description, "from": round(start, 3), "to": round(end, 3)})

return {
"labels": [m.strftime("%m/%y") for m in months],
"counts": [counts.get(m, 0) for m in months],
"lanes": lanes,
}


def _open_inquiry_ageing(base_qs, today: date) -> list[dict]:
"""Open inquiries, longest-waiting first, with the days since their latest step.

The latest step is the newest milestone date that is set (received,
responded, intro meeting, decision). A milestone in the future — an intro
meeting already booked — counts as zero days: nothing is overdue yet.
"""
steps = [
("inquiry_date", _("Received")),
("contacted_date", _("Responded")),
("intro_date", _("Intro meeting")),
("decision_date", _("Decision")),
]
rows = []
for inquiry in base_qs.open():
step_date, step_label = max(
((getattr(inquiry, field), label) for field, label in steps if getattr(inquiry, field)),
key=lambda pair: pair[0],
)
days = max((today - step_date).days, 0)
rows.append(
{
"inquiry": inquiry,
"days": days,
"step": step_label,
"step_date": step_date,
"stale": days > _STALE_DAYS,
}
)
rows.sort(key=lambda row: row["days"], reverse=True)
longest = rows[0]["days"] if rows else 0
for row in rows:
row["pct"] = round(100 * row["days"] / longest) if longest else 0
return rows


def _build_inquiry_analytics(request) -> dict:
"""Compute funnel counts, time-in-stage averages, source breakdown, and monthly trend."""
base_qs = ClientInquiry.objects.for_current_practice(request)
today = timezone.localdate()

# --- Funnel counts by status ---
status_counts: dict[str, int] = {
Expand Down Expand Up @@ -150,34 +244,14 @@ def _avg_days(from_field: str, to_field: str) -> tuple[float | None, int]:
for r in lang_rows
]

# --- Monthly trend (last 12 months, by inquiry_date) ---
range_start = DateRangeHelper.add_months(
date(timezone.localdate().year, timezone.localdate().month, 1), -11
)
monthly_raw = (
base_qs.filter(inquiry_date__gte=range_start)
.annotate(month=TruncMonth("inquiry_date"))
.values("month")
.annotate(count=Count("id"))
.order_by("month")
)
max_monthly = max((row["count"] for row in monthly_raw), default=1)
monthly_trend = [
{
"month": row["month"].strftime("%b %y"),
"count": row["count"],
"pct": round(100 * row["count"] / max_monthly),
}
for row in monthly_raw
]

return {
"funnel_stages": funnel_stages,
"closed_count": closed_count,
"time_in_stage": time_in_stage,
"source_breakdown": source_breakdown,
"language_breakdown": language_breakdown,
"monthly_trend": monthly_trend,
"monthly_chart": _monthly_chart(base_qs, request.current_practice, today),
"open_ageing": _open_inquiry_ageing(base_qs, today),
"total_inquiries": sum(status_counts.values()),
}

Expand Down
14 changes: 8 additions & 6 deletions app/static/css/tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -892,12 +892,14 @@
.source-bar-wrap { flex: 1; background: var(--color-bg-muted); border-radius: 3px; height: 8px; overflow: hidden; }
.source-bar { height: 100%; background: var(--color-badge-blue-bg); border-radius: 3px; min-width: 2px; }
.source-count { font-size: 0.82rem; font-weight: 600; min-width: 1.5rem; text-align: right; }
.monthly-bars { display: flex; align-items: flex-end; gap: 0.3rem; height: 4rem; }
.monthly-bar-col { display: flex; flex-direction: column; align-items: center; flex: 1; height: 100%; justify-content: flex-end; }
.monthly-bar-wrap { width: 100%; height: 100%; display: flex; align-items: flex-end; }
.monthly-bar { width: 100%; background: var(--color-badge-blue-text, #3182ce); border-radius: 2px 2px 0 0; min-height: 2px; opacity: 0.75; }
.monthly-count { font-size: 0.7rem; font-weight: 600; color: var(--color-text-muted); line-height: 1; margin-bottom: 0.15rem; }
.monthly-label { font-size: 0.65rem; color: var(--color-text-muted); margin-top: 0.25rem; white-space: nowrap; }
.ageing-list { display: flex; flex-direction: column; gap: 0.3rem; }
.ageing-row { display: grid; grid-template-columns: minmax(8rem, 12rem) 7.5rem 1fr 10rem; align-items: center; gap: 0.6rem; font-size: 0.85rem; }
.ageing-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ageing-row .status-badge { justify-self: start; font-size: 0.72rem; }
.ageing-bar { height: 100%; background: var(--color-chart-1); border-radius: 3px; min-width: 2px; }
.ageing-bar--stale { background: var(--color-warning); }
.ageing-days { font-weight: 600; white-space: nowrap; }
.ageing-step { font-weight: 400; color: var(--color-text-muted); }
.milestone-dates-fieldset { border: 1px solid var(--color-border); border-radius: 6px; padding: 0.75rem 1rem; margin-bottom: 1rem; }
.milestone-dates-fieldset legend { padding: 0 0.4rem; font-size: 0.85rem; font-weight: 600; color: var(--color-text-muted); }
.fieldset-hint { font-weight: 400; font-size: 0.78rem; }
Expand Down
60 changes: 59 additions & 1 deletion app/static/js/charts.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
*
* Labels in "MM/YY" form ("01/25") get a month axis: every January is marked
* with its year and a dashed separator, other months thin out to quarters.
*
* A bar chart's `lanes` ([{label, from, to}], positions in bar slots — slot i
* spans i - 0.5 … i + 0.5) are drawn as labelled strips above the bars, one
* row each so overlapping periods stay readable, with a dashed line wherever
* a lane starts or ends inside the axis.
*/
(function (root) {
"use strict";
Expand Down Expand Up @@ -101,6 +106,55 @@
Chart.defaults.animation = false;
Chart.defaults.plugins.tooltip.padding = 10;

const LANE_HEIGHT = 18;
const LANE_COLOURS = ["chart3", "chart2", "chart5", "chart6"];

// Labelled strips above the plot area, e.g. marketing periods on a month axis.
const lanes = {
id: "lanes",
beforeDatasetsDraw: function (chart, _args, opts) {
if (!opts.items || !opts.items.length) return;
const x = chart.scales.x;
const area = chart.chartArea;
const n = chart.data.labels.length;
const step = n > 1 ? x.getPixelForValue(1) - x.getPixelForValue(0) : area.right - area.left;
const px = function (slot) {
return x.getPixelForValue(0) + slot * step;
};
const ctx = chart.ctx;
ctx.save();
ctx.font = "11px " + Chart.defaults.font.family;
ctx.textBaseline = "middle";
opts.items.forEach(function (lane, row) {
const token = LANE_COLOURS[row % LANE_COLOURS.length];
const left = px(lane.from);
const right = px(lane.to);
const top = area.top - (opts.items.length - row) * LANE_HEIGHT - 4;
ctx.fillStyle = fade(token, 0.3)();
ctx.fillRect(left, top, right - left, LANE_HEIGHT - 3);
ctx.save();
ctx.beginPath();
ctx.rect(left, top, right - left, LANE_HEIGHT - 3);
ctx.clip();
ctx.fillStyle = theme.text;
ctx.fillText(lane.label, left + 4, top + (LANE_HEIGHT - 3) / 2);
ctx.restore();

ctx.strokeStyle = colour(token)();
ctx.setLineDash([3, 3]);
[lane.from, lane.to].forEach(function (slot) {
if (slot <= -0.5 || slot >= n - 0.5) return;
ctx.beginPath();
ctx.moveTo(px(slot), top + LANE_HEIGHT - 3);
ctx.lineTo(px(slot), area.bottom);
ctx.stroke();
});
ctx.setLineDash([]);
});
ctx.restore();
},
};

// Dashed line before each January on a month axis.
const yearSeparators = {
id: "yearSeparators",
Expand Down Expand Up @@ -270,7 +324,7 @@
});
}

/** Bar chart; several series give grouped bars with a legend. */
/** Bar chart; several series give grouped bars with a legend. lanes: see the header. */
function bar(canvas, opts) {
const datasets = opts.series.map(function (s) {
return {
Expand All @@ -281,13 +335,16 @@
maxBarThickness: 80,
};
});
const laneItems = opts.lanes || [];
return make(canvas, {
type: "bar",
data: { labels: opts.labels, datasets: datasets },
options: {
interaction: { mode: "index", intersect: false },
layout: { padding: { top: laneItems.length ? laneItems.length * LANE_HEIGHT + 6 : 0 } },
scales: scales(opts),
plugins: {
lanes: { items: laneItems },
legend: {
display: datasets.length > 1,
position: "top",
Expand All @@ -297,6 +354,7 @@
tooltip: tooltip(opts),
},
},
plugins: [lanes],
});
}

Expand Down
Loading
Loading