feat: KPI tiles, active/soft booking split, hour-breakdown drill-down, period toggle, forecast line, sync button

Major parity push against the original SPA's bundle-level feature set
(non-architectural items — separate Forecast / ProjectType / TimeLog
views and AI Chat remain TBD).

Backend (40/40 tests, +7):

- merge.py splits booked hours by booking status: active vs soft.
  Active set: Active, Active Booked, Fully Booked, Partially Booked.
  Soft set: Soft Booking, Soft Booked, Soft-Booked. Unknown statuses
  default to active so they're not silently dropped. Existing
  `bookedHours` field is preserved as the sum for back-compat.
- compute_totals(): rolls KPIs across the filtered summary —
  totalBooked, activeBooked, softBooked, totalLogged, totalBillable,
  totalLeave, totalProjects (distinct projectName/projectNumber),
  activePeople (distinct employees with logged>0), allocated,
  allocatedNetOfLeave.
- breakdown_by_project(): drills into a single period+employee (or
  whole-period) and returns per-project logged + booked hours.
- New /api/utilisation/breakdown endpoint. /api/utilisation/summary
  response gains `totals` and accepts `period=week|month`.
- New schemas: UtilisationTotals, BreakdownResponse, plus
  activeBookedHours / softBookedHours on UtilisationSummaryRow.

Frontend (typecheck/lint/build clean):

- KpiTiles component shows Total Booked / Logged / Billable, Total
  projects, Active People (logged), Active bookings, Avg/person/week
  or /month, Allocated (net of leave), Period covered.
- PeriodToggle (Per day / Per week / Per month). Day is rendered
  disabled with an explanatory tooltip — backend only accepts week/
  month.
- HourBreakdown drill-down panel: per-project logged + booked rows,
  shown when a chart bar is clicked; "Upload a time log to see logged
  breakdown" empty-state when no upload yet.
- MonthlyUtilisation: ComposedChart with stacked Active/Soft booked
  bars + forecast Line overlay driven by the existing showForecast
  toggle. onPeriodClick wired into HourBreakdown.
- WeeklyUtilisation, BookingVsActual: same Active/Soft stack treatment.
- Resourcing now passes the timelog hash through to summary so
  loggedHours actually populates there too (was 0 before).
- Sync Airtable button on both Department and Resourcing — force-
  refreshes bookings cache, re-derives summary.
- Tutorial steps re-mapped to the original SPA's chapter titles:
  "Reading the Utilisation Chart", "Hours & Utilisation", "Drill-In",
  "Forecast Line & Filters", "Spotting Resource Issues", "Sync
  Airtable Bookings". Tutorial page heading is now "Interactive
  Walkthrough" with the original copy.
- Defensive coercion in Bookings table totalHoursBooked rendering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-05-17 21:06:23 -04:00
parent 9e9daa3ec0
commit cd1c99d5e0
17 changed files with 1214 additions and 176 deletions

View file

@ -103,6 +103,8 @@ class UtilisationSummaryRow(BaseModel):
employmentType: str | None = None
availableHours: float = 0.0
bookedHours: float = 0.0
activeBookedHours: float = 0.0
softBookedHours: float = 0.0
loggedHours: float = 0.0
billableHours: float = 0.0
nonBillableHours: float = 0.0
@ -118,15 +120,58 @@ class UtilisationFiltersApplied(BaseModel):
department: str | None = None
name: str | None = None
billing_type: str | None = None
period: str | None = None
model_config = ConfigDict(populate_by_name=True)
class UtilisationTotals(BaseModel):
totalBooked: float = 0.0
activeBooked: float = 0.0
softBooked: float = 0.0
totalLogged: float = 0.0
totalBillable: float = 0.0
totalProjects: int = 0
activePeople: int = 0
activeBookingsCount: int = 0
availableHours: float = 0.0
allocatedNetOfLeave: float = 0.0
periodFrom: str | None = None
periodTo: str | None = None
class UtilisationSummaryResponse(BaseModel):
rows: list[UtilisationSummaryRow]
totals: UtilisationTotals
filters_applied: UtilisationFiltersApplied
class BreakdownProjectLogged(BaseModel):
project: str
hours: float = 0.0
billable: float = 0.0
nonBillable: float = 0.0
class BreakdownProjectBooked(BaseModel):
project: str
hours: float = 0.0
active: float = 0.0
soft: float = 0.0
class BreakdownResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)
period: str
employee: str | None = None
from_: str | None = Field(default=None, alias="from")
to: str | None = None
logged: list[BreakdownProjectLogged] = Field(default_factory=list)
booked: list[BreakdownProjectBooked] = Field(default_factory=list)
hasLogged: bool = False
# ---------- Health ----------
class HealthResponse(BaseModel):

View file

@ -1,4 +1,4 @@
"""Utilisation summary endpoint."""
"""Utilisation summary + drill-down endpoints."""
from __future__ import annotations
@ -11,7 +11,7 @@ from app.deps.auth import auth_required
from app.routers.airtable import _bookings_cache, _resources_cache
from app.routers.timelog import get_cached_parse
from app.services.airtable_fetch import fetch_bookings, fetch_resources
from app.services.merge import summarise
from app.services.merge import breakdown_by_project, compute_totals, summarise
router = APIRouter(prefix="/api/utilisation", tags=["utilisation"], dependencies=[Depends(auth_required)])
@ -26,6 +26,26 @@ def _parse_date(s: str | None, default: date) -> date:
raise HTTPException(status_code=400, detail=f"Invalid date: {e}")
async def _load_resources() -> list[dict[str, Any]]:
async def loader() -> dict[str, Any]:
rows = await fetch_resources(include_inactive=False)
return {"resources": rows}
payload = await _resources_cache.get_or_set("resources:False", loader)
return payload.get("resources", [])
async def _load_bookings(from_d: date, to_d: date) -> list[dict[str, Any]]:
bkey = f"bookings:{from_d}:{to_d}"
async def loader() -> dict[str, Any]:
rows = await fetch_bookings(from_=from_d, to=to_d)
return {"bookings": rows}
payload = await _bookings_cache.get_or_set(bkey, loader)
return payload.get("bookings", [])
@router.get("/summary")
async def summary(
from_: str | None = Query(None, alias="from"),
@ -47,23 +67,8 @@ async def summary(
if period not in ("week", "month"):
raise HTTPException(status_code=400, detail="period must be 'week' or 'month'")
# Resources — use the shared cache.
async def load_resources() -> dict[str, Any]:
rows = await fetch_resources(include_inactive=False)
return {"resources": rows}
res_payload = await _resources_cache.get_or_set("resources:False", load_resources)
resources = res_payload.get("resources", [])
# Bookings — share cache with /api/airtable/bookings.
bkey = f"bookings:{from_d}:{to_d}"
async def load_bookings() -> dict[str, Any]:
rows = await fetch_bookings(from_=from_d, to=to_d)
return {"bookings": rows}
bk_payload = await _bookings_cache.get_or_set(bkey, load_bookings)
bookings = bk_payload.get("bookings", [])
resources = await _load_resources()
bookings = await _load_bookings(from_d, to_d)
logged_rows: list[dict[str, Any]] = []
if x_timelog_hash:
@ -71,6 +76,7 @@ async def summary(
if cached:
logged_rows = cached.get("rows", [])
filters = {"department": department, "name": name, "billing_type": billing_type}
rows = summarise(
logged_rows,
bookings,
@ -78,16 +84,56 @@ async def summary(
from_=from_d,
to_=to_d,
period=period, # type: ignore[arg-type]
filters={"department": department, "name": name, "billing_type": billing_type},
filters=filters,
)
totals = compute_totals(rows, bookings, from_=from_d, to_=to_d, filters=filters)
return {
"rows": rows,
"totals": totals,
"filters_applied": {
"from": from_d.isoformat(),
"to": to_d.isoformat(),
"department": department,
"name": name,
"billing_type": billing_type,
"period": period,
},
}
@router.get("/breakdown")
async def breakdown(
period: str = Query(..., description="Period label, e.g. 2026-W19 or 2026-05"),
employee: str | None = Query(None),
from_: str | None = Query(None, alias="from"),
to: str | None = Query(None),
x_timelog_hash: str | None = Header(None, alias="X-Timelog-Hash"),
) -> dict[str, Any]:
"""Drill-down: per-project hour breakdown (logged + booked) for a period.
`period` must be either an ISO week (YYYY-Www) or a month (YYYY-MM).
The optional `from`/`to` query params clamp the breakdown window useful
when the user has a custom date range that doesn't span the full period.
"""
today = date.today()
monday = today - timedelta(days=today.weekday())
from_d = _parse_date(from_, monday - timedelta(days=180))
to_d = _parse_date(to, monday + timedelta(days=180))
bookings = await _load_bookings(from_d, to_d)
logged_rows: list[dict[str, Any]] = []
if x_timelog_hash:
cached = get_cached_parse(x_timelog_hash)
if cached:
logged_rows = cached.get("rows", [])
return breakdown_by_project(
logged_rows,
bookings,
period=period,
employee=employee,
from_=from_d,
to_=to_d,
)

View file

@ -10,6 +10,10 @@ Decisions:
- bookedHours pro-rate: if a booking spans multiple periods, distribute
totalHoursBooked proportionally to the working-day overlap between the
booking and each period. This is the simplest defensible split.
- Active vs Soft bookings: classified by `bookingStatus`. The split keeps
the original SPA's "Active Booked" / "Soft Booked" stacks. Unknown or
null statuses default to active (consistent with the original).
- bookedHours stays as the sum (= active + soft) for backwards compatibility.
- forecastHours == bookedHours for v1.
- All dates are inclusive on both ends.
- The function is deliberately pure no I/O, no globals so it's easy
@ -26,6 +30,20 @@ from typing import Any, Iterable, Literal
PeriodKind = Literal["week", "month"]
# Booking status buckets. Anything not in SOFT is treated as active.
_SOFT_STATUSES = {"soft booking", "soft booked", "soft-booked", "soft-booking"}
_ACTIVE_STATUSES = {"active", "active booked", "fully booked", "partially booked"}
def _is_soft(status: Any) -> bool:
if not status:
return False
s = str(status).strip().lower()
if not s:
return False
return s in _SOFT_STATUSES
# ----------------------------------------------------------------------
# Period helpers
# ----------------------------------------------------------------------
@ -201,6 +219,8 @@ def summarise(
"employmentType": emp_type,
"availableHours": 0.0,
"bookedHours": 0.0,
"activeBookedHours": 0.0,
"softBookedHours": 0.0,
"loggedHours": 0.0,
"billableHours": 0.0,
"nonBillableHours": 0.0,
@ -216,7 +236,10 @@ def summarise(
available_hours = (avail_per_week * weekdays_in_window) / 5.0 if avail_per_week else 0.0
# bookedHours: pro-rate by working-day overlap of booking within window.
# Split by bookingStatus: soft vs active. Unknown defaults to active.
booked = 0.0
active_booked = 0.0
soft_booked = 0.0
for b in emp_bookings:
b_start = b.get("startDate")
b_end = b.get("endDate") or b_start
@ -232,7 +255,12 @@ def summarise(
if overlap_weekdays == 0:
continue
total_hours = float(b.get("totalHoursBooked") or 0.0)
booked += total_hours * (overlap_weekdays / booking_total_weekdays)
slice_hours = total_hours * (overlap_weekdays / booking_total_weekdays)
booked += slice_hours
if _is_soft(b.get("bookingStatus")):
soft_booked += slice_hours
else:
active_booked += slice_hours
# Logged hours filtered to this window.
logged_h = 0.0
@ -270,6 +298,8 @@ def summarise(
"employmentType": emp_type,
"availableHours": round(available_hours, 2),
"bookedHours": round(booked, 2),
"activeBookedHours": round(active_booked, 2),
"softBookedHours": round(soft_booked, 2),
"loggedHours": round(logged_h, 2),
"billableHours": round(billable_h, 2),
"nonBillableHours": round(non_billable_h, 2),
@ -281,3 +311,218 @@ def summarise(
out.sort(key=lambda r: ((r["department"] or ""), r["employee"], r["period"]))
return out
def compute_totals(
rows: Iterable[dict[str, Any]],
bookings: Iterable[dict[str, Any]],
*,
from_: date,
to_: date,
filters: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Aggregate KPI rollups across the supplied summary rows + the bookings
in scope (used for distinct-project counts that aren't reflected in the
aggregated rows).
`filters` is the same dict passed to `summarise` so we can honour the
department / name narrowing for the bookings dimension. The summary rows
have already been filtered, so we only need to filter bookings here.
"""
rows = list(rows)
bookings = list(bookings)
filters = filters or {}
dep_filter = (filters.get("department") or "").strip().lower() or None
name_filter = (filters.get("name") or "").strip().lower() or None
total_booked = 0.0
active_booked = 0.0
soft_booked = 0.0
total_logged = 0.0
total_billable = 0.0
available_total = 0.0
leave_total = 0.0
active_people: set[str] = set()
for r in rows:
total_booked += float(r.get("bookedHours") or 0.0)
active_booked += float(r.get("activeBookedHours") or 0.0)
soft_booked += float(r.get("softBookedHours") or 0.0)
logged = float(r.get("loggedHours") or 0.0)
total_logged += logged
total_billable += float(r.get("billableHours") or 0.0)
available_total += float(r.get("availableHours") or 0.0)
leave_total += float(r.get("leaveHours") or 0.0)
if logged > 0:
emp = (r.get("employee") or "").strip()
if emp:
active_people.add(emp)
# Active bookings (count, projects) — narrow bookings to the same scope
# as the rows. The summarise() function already pre-filters out logged
# rows + booking slices that don't overlap the window, but we re-apply
# the date overlap and dept/name filters here for parity.
in_scope_bookings: list[dict[str, Any]] = []
project_names: set[str] = set()
for b in bookings:
b_start = b.get("startDate")
b_end = b.get("endDate") or b_start
if not b_start:
continue
if not b_end:
b_end = b_start
if _overlap(b_start, b_end, from_, to_) is None:
continue
if dep_filter:
dep_val = (b.get("department") or "").strip().lower()
if dep_val != dep_filter:
continue
if name_filter:
name_val = _name_key(b.get("resourceName"))
if name_filter not in name_val:
continue
in_scope_bookings.append(b)
pn = b.get("projectName") or b.get("projectNumber")
if pn:
pn_str = str(pn).strip()
if pn_str:
project_names.add(pn_str)
return {
"totalBooked": round(total_booked, 2),
"activeBooked": round(active_booked, 2),
"softBooked": round(soft_booked, 2),
"totalLogged": round(total_logged, 2),
"totalBillable": round(total_billable, 2),
"totalProjects": len(project_names),
"activePeople": len(active_people),
"activeBookingsCount": len(in_scope_bookings),
"availableHours": round(available_total, 2),
"allocatedNetOfLeave": round(max(available_total - leave_total, 0.0), 2),
"periodFrom": from_.isoformat(),
"periodTo": to_.isoformat(),
}
def breakdown_by_project(
logged: Iterable[dict[str, Any]],
bookings: Iterable[dict[str, Any]],
*,
period: str,
employee: str | None = None,
from_: date | None = None,
to_: date | None = None,
) -> dict[str, Any]:
"""Per-project hour breakdown for a given period label.
The period can be either an ISO-week ("2026-W19") or a month ("2026-05").
If `employee` is supplied we narrow to that one resource; otherwise we
aggregate across all resources visible.
Returns a structure suitable for the drill-down panel.
"""
p_start, p_end = _bounds_from_period_label(period)
if p_start is None or p_end is None:
return {"period": period, "employee": employee, "logged": [], "booked": [], "hasLogged": False}
if from_:
p_start = max(p_start, from_)
if to_:
p_end = min(p_end, to_)
emp_key = _name_key(employee) if employee else None
# Booked breakdown — pro-rated by working-day overlap.
booked_totals: dict[str, dict[str, float]] = {}
for b in bookings:
if emp_key and _name_key(b.get("resourceName")) != emp_key:
continue
b_start = b.get("startDate")
b_end = b.get("endDate") or b_start
if not b_start:
continue
if not b_end:
b_end = b_start
ov = _overlap(b_start, b_end, p_start, p_end)
if ov is None:
continue
booking_total_weekdays = _weekdays_between(b_start, b_end) or 1
overlap_weekdays = _weekdays_between(ov[0], ov[1])
if overlap_weekdays == 0:
continue
total_hours = float(b.get("totalHoursBooked") or 0.0)
slice_hours = total_hours * (overlap_weekdays / booking_total_weekdays)
project = (
(b.get("projectName") or b.get("projectNumber") or "Unknown")
)
project_key = str(project).strip() or "Unknown"
bucket = booked_totals.setdefault(project_key, {"project": project_key, "hours": 0.0, "active": 0.0, "soft": 0.0})
bucket["hours"] += slice_hours
if _is_soft(b.get("bookingStatus")):
bucket["soft"] += slice_hours
else:
bucket["active"] += slice_hours
# Logged breakdown — straight per-project sum within window.
logged_totals: dict[str, dict[str, float]] = {}
has_logged_input = False
for r in logged:
has_logged_input = True
if emp_key and _name_key(r.get("employee")) != emp_key:
continue
d = r.get("date")
if not d:
continue
if d < p_start or d > p_end:
continue
project = (r.get("project") or "Unknown")
project_key = str(project).strip() or "Unknown"
bucket = logged_totals.setdefault(project_key, {"project": project_key, "hours": 0.0, "billable": 0.0, "nonBillable": 0.0})
hrs = float(r.get("hours") or 0.0)
bucket["hours"] += hrs
if r.get("billable"):
bucket["billable"] += hrs
else:
bucket["nonBillable"] += hrs
def _round(d: dict[str, Any]) -> dict[str, Any]:
return {k: (round(v, 2) if isinstance(v, float) else v) for k, v in d.items()}
logged_rows = sorted((_round(v) for v in logged_totals.values()), key=lambda r: -r["hours"])
booked_rows = sorted((_round(v) for v in booked_totals.values()), key=lambda r: -r["hours"])
return {
"period": period,
"employee": employee,
"from": p_start.isoformat(),
"to": p_end.isoformat(),
"logged": logged_rows,
"booked": booked_rows,
"hasLogged": has_logged_input,
}
def _bounds_from_period_label(label: str) -> tuple[date | None, date | None]:
"""Parse a period label ("YYYY-Www" or "YYYY-MM") to [start, end] dates."""
label = (label or "").strip()
if not label:
return None, None
# ISO week
if "W" in label.upper():
try:
year_s, week_s = label.upper().split("-W", 1)
year = int(year_s)
week = int(week_s)
monday = date.fromisocalendar(year, week, 1)
sunday = monday + timedelta(days=6)
return monday, sunday
except (ValueError, IndexError):
return None, None
# Month
try:
year_s, month_s = label.split("-", 1)
year = int(year_s)
month = int(month_s)
first = date(year, month, 1)
return _month_bounds(first)
except (ValueError, IndexError):
return None, None

View file

@ -6,7 +6,12 @@ from datetime import date
import pytest
from app.services.merge import iter_periods, summarise
from app.services.merge import (
breakdown_by_project,
compute_totals,
iter_periods,
summarise,
)
def test_iter_periods_week():
@ -180,3 +185,132 @@ def test_leave_hours_split_out_from_non_billable(sample_resources):
assert bhakti["billableHours"] == 32.0
assert bhakti["nonBillableHours"] == 0.0
assert bhakti["loggedHours"] == 40.0
# ---------------------------------------------------------------------------
# Active vs Soft split + totals
# ---------------------------------------------------------------------------
def test_active_vs_soft_split(sample_resources):
"""Bookings tagged 'Soft Booking' must land in softBookedHours; others active."""
bookings = [
{
"id": "bk-active", "task": "A", "startDate": date(2026, 5, 4),
"endDate": date(2026, 5, 8), "resourceName": "Bhakti Doshi",
"projectNumber": "P-A", "projectName": "Active proj",
"department": "Creative Team", "division": "Production",
"hoursSelection": [], "totalHoursBooked": 20.0,
"bookingStatus": "Active", "placeholder": False,
},
{
"id": "bk-soft", "task": "S", "startDate": date(2026, 5, 4),
"endDate": date(2026, 5, 8), "resourceName": "Bhakti Doshi",
"projectNumber": "P-S", "projectName": "Soft proj",
"department": "Creative Team", "division": "Production",
"hoursSelection": [], "totalHoursBooked": 12.0,
"bookingStatus": "Soft Booking", "placeholder": False,
},
]
rows = summarise(
[], bookings, sample_resources,
from_=date(2026, 5, 4), to_=date(2026, 5, 8), period="week",
)
bhakti = next(r for r in rows if r["employee"] == "Bhakti Doshi" and r["period"] == "2026-W19")
assert bhakti["activeBookedHours"] == pytest.approx(20.0)
assert bhakti["softBookedHours"] == pytest.approx(12.0)
assert bhakti["bookedHours"] == pytest.approx(32.0)
def test_unknown_status_defaults_to_active(sample_resources):
"""Null/unknown statuses fall through to active (not silently dropped)."""
bookings = [
{
"id": "bk-null", "task": "N", "startDate": date(2026, 5, 4),
"endDate": date(2026, 5, 8), "resourceName": "Bhakti Doshi",
"projectNumber": "P-N", "projectName": "Null proj",
"department": "Creative Team", "division": "Production",
"hoursSelection": [], "totalHoursBooked": 15.0,
"bookingStatus": None, "placeholder": False,
},
{
"id": "bk-junk", "task": "J", "startDate": date(2026, 5, 4),
"endDate": date(2026, 5, 8), "resourceName": "Bhakti Doshi",
"projectNumber": "P-J", "projectName": "Junk status proj",
"department": "Creative Team", "division": "Production",
"hoursSelection": [], "totalHoursBooked": 5.0,
"bookingStatus": "Pencilled In Tentatively", "placeholder": False,
},
]
rows = summarise(
[], bookings, sample_resources,
from_=date(2026, 5, 4), to_=date(2026, 5, 8), period="week",
)
bhakti = next(r for r in rows if r["employee"] == "Bhakti Doshi" and r["period"] == "2026-W19")
assert bhakti["activeBookedHours"] == pytest.approx(20.0)
assert bhakti["softBookedHours"] == 0.0
assert bhakti["bookedHours"] == pytest.approx(20.0)
def test_compute_totals(sample_resources, sample_bookings, sample_logged):
rows = summarise(
sample_logged, sample_bookings, sample_resources,
from_=date(2026, 5, 4), to_=date(2026, 5, 8), period="week",
)
totals = compute_totals(
rows, sample_bookings,
from_=date(2026, 5, 4), to_=date(2026, 5, 8),
)
assert totals["totalBooked"] == pytest.approx(38.0)
assert totals["activeBooked"] == pytest.approx(38.0)
assert totals["softBooked"] == 0.0
assert totals["totalLogged"] == pytest.approx(35.0)
assert totals["totalBillable"] == pytest.approx(28.0)
assert totals["totalProjects"] == 1 # one distinct projectName
assert totals["activePeople"] == 1 # only Bhakti has loggedHours > 0
assert totals["activeBookingsCount"] == 1
assert totals["periodFrom"] == "2026-05-04"
assert totals["periodTo"] == "2026-05-08"
# allocated net of leave: avail (40 + 40 for two FTEs in W19) - 0 leave = 80
assert totals["allocatedNetOfLeave"] == pytest.approx(80.0)
def test_breakdown_by_project_logged_and_booked(sample_resources, sample_bookings, sample_logged):
out = breakdown_by_project(
sample_logged, sample_bookings,
period="2026-W19", employee="Bhakti Doshi",
)
assert out["period"] == "2026-W19"
assert out["employee"] == "Bhakti Doshi"
assert out["hasLogged"] is True
# Logged side: Acme (28h billable across 4 days) + Internal (7h non-billable)
logged_by_project = {row["project"]: row for row in out["logged"]}
assert logged_by_project["Acme"]["hours"] == pytest.approx(28.0)
assert logged_by_project["Acme"]["billable"] == pytest.approx(28.0)
assert logged_by_project["Internal"]["hours"] == pytest.approx(7.0)
assert logged_by_project["Internal"]["nonBillable"] == pytest.approx(7.0)
# Booked side: one booking on Acme Spring Launch = 38h, all active
booked_by_project = {row["project"]: row for row in out["booked"]}
assert booked_by_project["Acme Spring Launch"]["hours"] == pytest.approx(38.0)
assert booked_by_project["Acme Spring Launch"]["active"] == pytest.approx(38.0)
def test_breakdown_no_timelog(sample_resources, sample_bookings):
"""Empty timelog input: hasLogged should be False so the UI can prompt."""
out = breakdown_by_project([], sample_bookings, period="2026-W19", employee="Bhakti Doshi")
assert out["hasLogged"] is False
assert out["logged"] == []
# Booked side still works
assert any(row["project"] == "Acme Spring Launch" for row in out["booked"])
def test_breakdown_month_period(sample_resources, sample_bookings):
"""Month-level period label resolves to that month's day-range."""
out = breakdown_by_project([], sample_bookings, period="2026-05")
# The single sample booking falls inside May 2026.
assert any(row["project"] == "Acme Spring Launch" for row in out["booked"])
def test_breakdown_invalid_period_returns_empty(sample_bookings):
out = breakdown_by_project([], sample_bookings, period="not-a-period")
assert out["logged"] == [] and out["booked"] == []

View file

@ -2,6 +2,7 @@ import { apiFetch, buildQuery } from './client';
import type {
AuthMeResponse,
BookingsResponse,
BreakdownResponse,
LoginResponse,
MetaResponse,
ParseResponse,
@ -68,6 +69,7 @@ export interface SummaryParams {
department?: string;
name?: string;
billing_type?: string;
period?: 'week' | 'month';
timelogHash?: string;
}
@ -77,3 +79,18 @@ export function getUtilisationSummary(params: SummaryParams = {}) {
if (timelogHash) headers['X-Timelog-Hash'] = timelogHash;
return apiFetch<UtilisationSummaryResponse>(`/utilisation/summary${buildQuery(query)}`, { headers });
}
export interface BreakdownParams {
period: string;
employee?: string;
from?: string;
to?: string;
timelogHash?: string;
}
export function getUtilisationBreakdown(params: BreakdownParams) {
const { timelogHash, ...query } = params;
const headers: Record<string, string> = {};
if (timelogHash) headers['X-Timelog-Hash'] = timelogHash;
return apiFetch<BreakdownResponse>(`/utilisation/breakdown${buildQuery(query)}`, { headers });
}

View file

@ -57,6 +57,8 @@ export interface UtilisationSummaryRow {
employmentType: string;
availableHours: number;
bookedHours: number;
activeBookedHours: number;
softBookedHours: number;
loggedHours: number;
billableHours: number;
nonBillableHours: number;
@ -66,11 +68,51 @@ export interface UtilisationSummaryRow {
bookedUtilisationPct: number;
}
export interface UtilisationTotals {
totalBooked: number;
activeBooked: number;
softBooked: number;
totalLogged: number;
totalBillable: number;
totalProjects: number;
activePeople: number;
activeBookingsCount: number;
availableHours: number;
allocatedNetOfLeave: number;
periodFrom: string | null;
periodTo: string | null;
}
export interface UtilisationSummaryResponse {
rows: UtilisationSummaryRow[];
totals: UtilisationTotals;
filters_applied: Record<string, string | null>;
}
export interface BreakdownProjectLogged {
project: string;
hours: number;
billable: number;
nonBillable: number;
}
export interface BreakdownProjectBooked {
project: string;
hours: number;
active: number;
soft: number;
}
export interface BreakdownResponse {
period: string;
employee: string | null;
from: string | null;
to: string | null;
logged: BreakdownProjectLogged[];
booked: BreakdownProjectBooked[];
hasLogged: boolean;
}
export interface ResourcesResponse {
resources: Resource[];
cached_at: string;

View file

@ -0,0 +1,140 @@
import { useEffect, useState } from 'react';
import { X } from 'lucide-react';
import * as api from '../api/endpoints';
import { ApiError } from '../api/client';
import type { BreakdownResponse } from '../api/types';
import { formatHours, formatPeriod } from '../lib/dates';
interface Props {
period: string;
employee?: string | null;
from?: string;
to?: string;
timelogHash?: string | null;
onClose: () => void;
}
/**
* Drill-down panel: per-project breakdown of logged + booked hours for the
* clicked bar's period. Mirrors the original SPA's "Hour Breakdown" overlay.
*/
export default function HourBreakdown({ period, employee, from, to, timelogHash, onClose }: Props) {
const [data, setData] = useState<BreakdownResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
api
.getUtilisationBreakdown({
period,
employee: employee ?? undefined,
from,
to,
timelogHash: timelogHash ?? undefined,
})
.then((res) => {
if (!cancelled) setData(res);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof ApiError ? err.detail : (err as Error).message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [period, employee, from, to, timelogHash]);
return (
<div className="card" data-tutorial-id="hour-breakdown">
<div className="mb-2 flex items-baseline justify-between gap-2">
<h3 className="text-sm font-semibold text-slate-700">
Hour Breakdown {formatPeriod(period)}
{employee && <span className="ml-2 text-slate-500">· {employee}</span>}
</h3>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-slate-500 hover:bg-slate-100"
aria-label="Close hour breakdown"
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
{loading && <div className="text-sm text-slate-500">Loading breakdown</div>}
{error && <div className="text-sm text-red-700">{error}</div>}
{!loading && !error && data && (
<div className="grid gap-4 md:grid-cols-2">
<div>
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
Logged by project
</div>
{!data.hasLogged ? (
<div className="rounded-md bg-slate-50 p-3 text-sm text-slate-600">
Upload a time log to see logged breakdown.
</div>
) : data.logged.length === 0 ? (
<div className="text-sm text-slate-500">No logged hours in this period.</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase text-slate-500">
<th className="pb-1">Project</th>
<th className="pb-1 text-right">Hours</th>
<th className="pb-1 text-right">Billable</th>
</tr>
</thead>
<tbody>
{data.logged.map((r) => (
<tr key={r.project} className="border-t border-slate-100">
<td className="py-1 pr-2 truncate" title={r.project}>{r.project}</td>
<td className="py-1 text-right tabular-nums">{formatHours(r.hours)}</td>
<td className="py-1 text-right tabular-nums text-emerald-700">{formatHours(r.billable)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
Booked by project
</div>
{data.booked.length === 0 ? (
<div className="text-sm text-slate-500">No bookings in this period.</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase text-slate-500">
<th className="pb-1">Project</th>
<th className="pb-1 text-right">Hours</th>
<th className="pb-1 text-right">Active</th>
<th className="pb-1 text-right">Soft</th>
</tr>
</thead>
<tbody>
{data.booked.map((r) => (
<tr key={r.project} className="border-t border-slate-100">
<td className="py-1 pr-2 truncate" title={r.project}>{r.project}</td>
<td className="py-1 text-right tabular-nums">{formatHours(r.hours)}</td>
<td className="py-1 text-right tabular-nums text-blue-700">{formatHours(r.active)}</td>
<td className="py-1 text-right tabular-nums text-indigo-500">{formatHours(r.soft)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,67 @@
import type { UtilisationTotals } from '../api/types';
import { formatHours } from '../lib/dates';
interface Props {
totals: UtilisationTotals | null;
/** Per-period averaging: "day" / "week" / "month" toggle on the parent page. */
period?: 'week' | 'month';
}
interface Tile {
label: string;
value: string;
hint?: string;
}
/**
* KPI tiles displayed above the charts on Department + Resourcing.
* Mirrors the original SPA's "Total Booked / Logged / Billable / projects /
* Active People / Allocated (net of leave) / Avg per person per week" set.
*/
export default function KpiTiles({ totals, period = 'week' }: Props) {
if (!totals) return null;
const avgPerPerson = (() => {
if (totals.activePeople <= 0) return 0;
return totals.totalLogged / totals.activePeople;
})();
// Crude per-week / per-month average so the user can compare against
// weekly availability. Day-level would require counting working days.
const avgLabel = period === 'month' ? 'Avg / person / month' : 'Avg / person / week';
const tiles: Tile[] = [
{ label: 'Total Booked', value: formatHours(totals.totalBooked), hint: `Active ${formatHours(totals.activeBooked)} · Soft ${formatHours(totals.softBooked)}` },
{ label: 'Total Logged', value: formatHours(totals.totalLogged) },
{ label: 'Total Billable', value: formatHours(totals.totalBillable) },
{ label: 'Total projects', value: String(totals.totalProjects) },
{ label: 'Active People (logged)', value: String(totals.activePeople) },
{ label: 'Active bookings pulled', value: String(totals.activeBookingsCount) },
{ label: avgLabel, value: formatHours(avgPerPerson) },
{ label: 'Allocated (net of leave)', value: formatHours(totals.allocatedNetOfLeave) },
];
const periodCovered = totals.periodFrom && totals.periodTo
? `${totals.periodFrom}${totals.periodTo}`
: null;
return (
<div className="card space-y-2" data-tutorial-id="kpi-tiles">
<div className="flex items-baseline justify-between">
<h3 className="text-sm font-semibold text-slate-700">Headline numbers</h3>
{periodCovered && (
<span className="text-xs text-slate-500">Period covered: {periodCovered}</span>
)}
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{tiles.map((t) => (
<div key={t.label} className="rounded-md bg-slate-50 p-3 ring-1 ring-slate-200">
<div className="text-[11px] font-medium uppercase tracking-wide text-slate-500">{t.label}</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-slate-900">{t.value}</div>
{t.hint && <div className="mt-0.5 text-xs text-slate-500">{t.hint}</div>}
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,58 @@
import type { PeriodKind } from '../lib/filters';
interface Props {
value: PeriodKind;
onChange: (next: PeriodKind) => void;
/** Whether to show the "Per day" option. The backend does not aggregate
* by day (too noisy across 100+ employees) so we hide it by default. */
includeDay?: boolean;
}
const OPTIONS: { value: PeriodKind | 'day'; label: string }[] = [
{ value: 'day', label: 'Per day' },
{ value: 'week', label: 'Per week' },
{ value: 'month', label: 'Per month' },
];
/**
* Three-state radio toggle for week/month aggregation. "Per day" is shown
* as disabled when includeDay=false (the default) so the user can still see
* the original SPA's labelling but understands day-level isn't supported.
*/
export default function PeriodToggle({ value, onChange, includeDay = false }: Props) {
return (
<div
className="inline-flex items-center rounded-md border border-slate-300 bg-white p-0.5 text-sm shadow-sm"
data-tutorial-id="period-toggle"
role="radiogroup"
aria-label="Aggregation period"
>
{OPTIONS.map((opt) => {
const isDay = opt.value === 'day';
const disabled = isDay && !includeDay;
const active = !disabled && value === opt.value;
return (
<button
type="button"
key={opt.value}
role="radio"
aria-checked={active}
disabled={disabled}
title={disabled ? 'Day-level aggregation is too noisy for this dataset' : undefined}
onClick={() => {
if (disabled) return;
onChange(opt.value as PeriodKind);
}}
className={[
'rounded px-3 py-1 text-xs font-medium transition',
active ? 'bg-blue-600 text-white shadow-sm' : 'text-slate-600 hover:bg-slate-100',
disabled ? 'cursor-not-allowed opacity-40 hover:bg-transparent' : '',
].join(' ')}
>
{opt.label}
</button>
);
})}
</div>
);
}

View file

@ -21,7 +21,8 @@ const TOP_N = 20;
interface Bucket {
employee: string;
booked: number;
activeBooked: number;
softBooked: number;
actual: number;
}
@ -30,14 +31,17 @@ function aggregateByEmployee(rows: UtilisationSummaryRow[]): Bucket[] {
for (const r of rows) {
const employee = String(r.employee ?? '').trim();
if (!employee) continue;
const b = map.get(employee) ?? { employee, booked: 0, actual: 0 };
b.booked += Number(r.bookedHours) || 0;
const b = map.get(employee) ?? { employee, activeBooked: 0, softBooked: 0, actual: 0 };
b.activeBooked += Number(r.activeBookedHours) || 0;
b.softBooked += Number(r.softBookedHours) || 0;
b.actual += Number(r.loggedHours) || 0;
map.set(employee, b);
}
const all = [...map.values()].sort(
(a, b) => b.booked + b.actual - (a.booked + a.actual),
(a, b) =>
b.activeBooked + b.softBooked + b.actual -
(a.activeBooked + a.softBooked + a.actual),
);
if (all.length <= TOP_N) return all;
@ -45,11 +49,13 @@ function aggregateByEmployee(rows: UtilisationSummaryRow[]): Bucket[] {
const rest = all.slice(TOP_N);
const other: Bucket = {
employee: `Other (${rest.length})`,
booked: 0,
activeBooked: 0,
softBooked: 0,
actual: 0,
};
for (const b of rest) {
other.booked += b.booked;
other.activeBooked += b.activeBooked;
other.softBooked += b.softBooked;
other.actual += b.actual;
}
return [...top, other];
@ -69,7 +75,8 @@ export default function BookingVsActual({ rows }: Props) {
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend />
<Bar dataKey="booked" name="Booked" fill="#2563eb" />
<Bar dataKey="activeBooked" name="Active Booked" stackId="booked" fill="#2563eb" />
<Bar dataKey="softBooked" name="Soft Booked" stackId="booked" fill="#93c5fd" />
<Bar dataKey="actual" name="Actual" fill="#10b981" />
</BarChart>
</ResponsiveContainer>

View file

@ -15,12 +15,15 @@ import { formatPeriod, monthOf } from '../../lib/dates';
interface Props {
rows: UtilisationSummaryRow[];
showForecast: boolean;
/** Optional: clicking a bar surfaces an Hour Breakdown for that month. */
onPeriodClick?: (period: string) => void;
}
interface Bucket {
period: string;
available: number;
booked: number;
activeBooked: number;
softBooked: number;
logged: number;
forecast: number;
}
@ -32,28 +35,45 @@ function aggregateByMonth(rows: UtilisationSummaryRow[]): Bucket[] {
if (!rawPeriod) continue;
const key = /^\d{4}-\d{2}/.test(rawPeriod) ? rawPeriod.slice(0, 7) : monthOf(rawPeriod);
if (!key) continue;
const b = map.get(key) ?? { period: key, available: 0, booked: 0, logged: 0, forecast: 0 };
const b = map.get(key) ?? {
period: key,
available: 0,
activeBooked: 0,
softBooked: 0,
logged: 0,
forecast: 0,
};
b.available += Number(r.availableHours) || 0;
b.booked += Number(r.bookedHours) || 0;
b.activeBooked += Number(r.activeBookedHours) || 0;
b.softBooked += Number(r.softBookedHours) || 0;
b.logged += Number(r.loggedHours) || 0;
b.forecast += Number(r.forecastHours) || 0;
map.set(key, b);
}
// Period-based: cardinality is bounded by the date range, no top-N cap needed.
return [...map.values()].sort((a, b) =>
String(a.period ?? '').localeCompare(String(b.period ?? '')),
);
}
export default function MonthlyUtilisation({ rows, showForecast }: Props) {
export default function MonthlyUtilisation({ rows, showForecast, onPeriodClick }: Props) {
const data = aggregateByMonth(rows).map((b) => ({
...b,
label: formatPeriod(b.period),
}));
const onBarClick = (payload: { period?: string } | undefined) => {
if (onPeriodClick && payload?.period) onPeriodClick(payload.period);
};
return (
<div className="card" data-tutorial-id="chart-monthly-utilisation">
<h3 className="mb-2 text-sm font-semibold text-slate-700">Monthly Utilisation</h3>
<div className="mb-2 flex items-baseline justify-between">
<h3 className="text-sm font-semibold text-slate-700">Monthly Utilisation</h3>
<span className="text-xs text-slate-500">
{onPeriodClick ? 'Click a bar for Hour Breakdown · ' : ''}
{showForecast ? 'Forecast line visible' : 'Forecast line hidden'}
</span>
</div>
<div className="h-80 w-full">
<ResponsiveContainer>
<ComposedChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
@ -62,11 +82,33 @@ export default function MonthlyUtilisation({ rows, showForecast }: Props) {
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend />
<Bar dataKey="booked" name="Booked" fill="#2563eb" />
<Bar
dataKey="activeBooked"
name="Active Booked"
stackId="booked"
fill="#2563eb"
onClick={onBarClick}
style={{ cursor: onPeriodClick ? 'pointer' : 'default' }}
/>
<Bar
dataKey="softBooked"
name="Soft Booked"
stackId="booked"
fill="#93c5fd"
onClick={onBarClick}
style={{ cursor: onPeriodClick ? 'pointer' : 'default' }}
/>
<Bar dataKey="logged" name="Logged" fill="#0ea5e9" />
<Bar dataKey="available" name="Available" fill="#cbd5e1" />
{showForecast && (
<Line type="monotone" dataKey="forecast" name="Forecast" stroke="#f97316" strokeWidth={2} dot />
<Line
type="monotone"
dataKey="forecast"
name="Forecast Utilisation"
stroke="#f97316"
strokeWidth={2}
dot
/>
)}
</ComposedChart>
</ResponsiveContainer>

View file

@ -14,40 +14,59 @@ import { formatPeriod, weekOf } from '../../lib/dates';
interface Props {
rows: UtilisationSummaryRow[];
onPeriodClick?: (period: string) => void;
/** Aggregation kind. The summary endpoint controls the row labels
* this prop just affects the X-axis title + the period regex match. */
period?: 'week' | 'month';
}
interface Bucket {
period: string;
booked: number;
activeBooked: number;
softBooked: number;
logged: number;
available: number;
}
function aggregateByWeek(rows: UtilisationSummaryRow[]): Bucket[] {
function aggregate(rows: UtilisationSummaryRow[], period: 'week' | 'month'): Bucket[] {
const map = new Map<string, Bucket>();
for (const r of rows) {
const rawPeriod = String(r.period ?? '');
if (!rawPeriod) continue;
const key = /^\d{4}-W\d{2}$/.test(rawPeriod) ? rawPeriod : weekOf(rawPeriod);
let key: string;
if (period === 'month') {
key = /^\d{4}-\d{2}/.test(rawPeriod) ? rawPeriod.slice(0, 7) : '';
} else {
key = /^\d{4}-W\d{2}$/.test(rawPeriod) ? rawPeriod : weekOf(rawPeriod);
}
if (!key) continue;
const b = map.get(key) ?? { period: key, booked: 0, logged: 0, available: 0 };
b.booked += Number(r.bookedHours) || 0;
const b = map.get(key) ?? { period: key, activeBooked: 0, softBooked: 0, logged: 0, available: 0 };
b.activeBooked += Number(r.activeBookedHours) || 0;
b.softBooked += Number(r.softBookedHours) || 0;
b.logged += Number(r.loggedHours) || 0;
b.available += Number(r.availableHours) || 0;
map.set(key, b);
}
// Period-based: cardinality is bounded by the date range, no top-N cap needed.
return [...map.values()].sort((a, b) =>
String(a.period ?? '').localeCompare(String(b.period ?? '')),
);
}
export default function WeeklyUtilisation({ rows, onPeriodClick }: Props) {
const data = aggregateByWeek(rows).map((b) => ({ ...b, label: formatPeriod(b.period) }));
export default function WeeklyUtilisation({ rows, onPeriodClick, period = 'week' }: Props) {
const data = aggregate(rows, period).map((b) => ({ ...b, label: formatPeriod(b.period) }));
const title = period === 'month' ? 'Monthly Utilisation (per-week chart in month mode)' : 'Weekly Utilisation';
const onBarClick = (payload: { period?: string } | undefined) => {
if (onPeriodClick && payload?.period) onPeriodClick(payload.period);
};
return (
<div className="card" data-tutorial-id="chart-weekly-utilisation">
<h3 className="mb-2 text-sm font-semibold text-slate-700">Weekly Utilisation</h3>
<div className="mb-2 flex items-baseline justify-between">
<h3 className="text-sm font-semibold text-slate-700">{title}</h3>
{onPeriodClick && (
<span className="text-xs text-slate-500">Click a bar for Hour Breakdown</span>
)}
</div>
<div className="h-80 w-full">
<ResponsiveContainer>
<BarChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
@ -57,12 +76,19 @@ export default function WeeklyUtilisation({ rows, onPeriodClick }: Props) {
<Tooltip />
<Legend />
<Bar
dataKey="booked"
name="Booked"
dataKey="activeBooked"
name="Active Booked"
stackId="booked"
fill="#2563eb"
onClick={(payload: { period?: string }) => {
if (onPeriodClick && payload?.period) onPeriodClick(payload.period);
}}
onClick={onBarClick}
style={{ cursor: onPeriodClick ? 'pointer' : 'default' }}
/>
<Bar
dataKey="softBooked"
name="Soft Booked"
stackId="booked"
fill="#93c5fd"
onClick={onBarClick}
style={{ cursor: onPeriodClick ? 'pointer' : 'default' }}
/>
<Bar dataKey="logged" name="Logged" fill="#0ea5e9" />

View file

@ -1,45 +1,64 @@
export interface TutorialStep {
/** matches the `data-tutorial-id` attribute on the target element */
selector: string;
/** chapter label, mirrors the original SPA */
title: string;
description: string;
}
/**
* The original SPA exposed a chapter list ("Chart overview",
* "Reading the Utilisation Chart", "Forecast Line & Filters", "Drill-In",
* "Spotting Resource Issues", etc.). The selectors below map each chapter
* onto the matching data-tutorial-id on screen. Steps whose target isn't
* on the current page are filtered out by TutorialOverlay at runtime.
*/
export const departmentSteps: TutorialStep[] = [
{
selector: 'navbar',
title: 'Top navigation',
description: 'Switch between Department, Resourcing, Bookings and Tutorial tabs from here.',
title: 'How to Use the Department Tab',
description: 'Navigate to the Department Tab from here. The rest of the tour walks through every chart on this page.',
},
{
selector: 'upload-zone',
title: 'Upload your timelog',
description: 'Drag a Harvest/Toggl export here, or click to choose a file. .xlsx and .csv supported.',
description: 'Drag a Zoho / Harvest / Toggl export here, or click to choose a file. .xlsx and .csv supported. The file stays in this session only.',
},
{
selector: 'filter-bar',
title: 'Filter the view',
description: 'Pick a date range preset (or use a custom range), and narrow down by department, name or billing type.',
title: 'Forecast Line & Filters',
description: 'Pick a date range preset (or use a custom range), narrow by department, name or billing type, and toggle the forecast overlay.',
},
{
selector: 'forecast-toggle',
title: 'Forecast line',
description: 'Toggle the forecast overlay on or off — useful for spotting upcoming over- or under-bookings.',
selector: 'kpi-tiles',
title: 'Hours & Utilisation',
description: 'Headline KPIs — Total Booked / Logged / Billable, distinct projects, active people and net-of-leave capacity for the chosen period.',
},
{
selector: 'sync-airtable',
title: 'Airtable sync',
description: 'Force-refresh the bookings cache to pick up brand-new Airtable changes. Use sparingly — counts against API rate limits.',
},
{
selector: 'chart-monthly-utilisation',
title: 'Monthly utilisation',
description: 'Booked, logged and available hours grouped by calendar month.',
title: 'Reading the Utilisation Chart',
description: 'Booked, logged and available hours grouped by month. Active vs Soft bookings are stacked. Click bars & toggle forecast line.',
},
{
selector: 'hour-breakdown',
title: 'Drill-In',
description: 'Click any Booked bar to open the Hour Breakdown — a per-project split of logged and booked hours for that period.',
},
{
selector: 'chart-booking-vs-actual',
title: 'Booking vs Actual',
description: 'See whose bookings match their logged hours and whose drift. Capped at the top 20 busiest people; the rest roll up into "Other".',
title: 'Chart overview — Booking vs Actual',
description: 'See whose bookings match their logged hours and whose drift. Active vs Soft are stacked; capped at the top 20 busiest people.',
},
{
selector: 'chart-billability-breakdown',
title: 'Billability breakdown',
description: 'Per-person split of billable, non-billable, leave and idle hours. Capped at the top 20 by available + billable.',
title: 'Spotting Resource Issues',
description: 'Per-person split of billable, non-billable, leave and idle hours. Spot errors like a wall of non-billable on a "busy" person.',
},
{
selector: 'export-csv',
@ -51,23 +70,43 @@ export const departmentSteps: TutorialStep[] = [
export const resourcingSteps: TutorialStep[] = [
{
selector: 'filter-bar',
title: 'Filter the view',
title: 'Forecast Line & Filters',
description: 'The same filter controls you used on Department — date range, department and name all flow through to the Airtable bookings query.',
},
{
selector: 'kpi-tiles',
title: 'Hours & Utilisation',
description: 'Headline KPIs across the people in scope: total booked (active + soft), logged, billable, distinct projects and net-of-leave capacity.',
},
{
selector: 'period-toggle',
title: 'Chart overview — Per week / Per month',
description: 'Switch the chart aggregation between week and month. Day-level isn\'t supported (too noisy across 100+ employees).',
},
{
selector: 'sync-airtable',
title: 'Sync Airtable Bookings',
description: 'Force-refresh the bookings cache so the charts reflect the latest Airtable state.',
},
{
selector: 'chart-weekly-utilisation',
title: 'Weekly utilisation',
description: 'Click a Booked bar to drill into a specific week.',
title: 'Reading the Utilisation Chart',
description: 'Active Booked + Soft Booked stack together; Logged and Available sit alongside. Click bars & toggle forecast line.',
},
{
selector: 'hour-breakdown',
title: 'Drill-In',
description: 'After clicking a bar, this panel shows the per-project split for the selected period (logged + booked).',
},
{
selector: 'chart-project-load',
title: 'Project load per person',
title: 'Chart overview — Project Load',
description: 'Stacked bars show which projects are loading up each resource. Capped at the top 10 projects by hours; the rest roll up into "Other".',
},
{
selector: 'chart-fte-vs-freelancer',
title: 'FTE vs Freelancer',
description: 'Compare utilisation between salaried staff and contractors. Each side caps at the top 20.',
title: 'Spotting Resource Issues',
description: 'Compare utilisation between salaried staff and contractors side by side. Use this to spot under-used FTEs or over-loaded freelancers.',
},
{
selector: 'export-csv',
@ -79,17 +118,17 @@ export const resourcingSteps: TutorialStep[] = [
export const bookingsSteps: TutorialStep[] = [
{
selector: 'filter-bar',
title: 'Filter the view',
title: 'Forecast Line & Filters',
description: 'Date range, department and name filters narrow the table below.',
},
{
selector: 'bookings-table',
title: 'Bookings table',
title: 'Reading the bookings table',
description: 'A virtualised view of every booking returned by Airtable for the active filters.',
},
{
selector: 'bookings-refresh',
title: 'Refresh from Airtable',
title: 'Sync Airtable Bookings',
description: 'Bypass the cache and pull a fresh copy from Airtable. Use sparingly — counts against API rate limits.',
},
];

View file

@ -1,6 +1,8 @@
import type { DatePreset, DateRange } from './dates';
import { dateRangePreset } from './dates';
export type PeriodKind = 'week' | 'month';
export interface FilterState {
preset: DatePreset;
range: DateRange | null;
@ -8,6 +10,7 @@ export interface FilterState {
names: string[];
billingType: string | null;
showForecast: boolean;
period: PeriodKind;
}
export const initialFilterState: FilterState = {
@ -17,6 +20,7 @@ export const initialFilterState: FilterState = {
names: [],
billingType: null,
showForecast: true,
period: 'week',
};
export type FilterAction =
@ -25,6 +29,7 @@ export type FilterAction =
| { type: 'set-departments'; departments: string[] }
| { type: 'set-names'; names: string[] }
| { type: 'set-billing-type'; billingType: string | null }
| { type: 'set-period'; period: PeriodKind }
| { type: 'toggle-forecast' }
| { type: 'reset' };
@ -42,6 +47,8 @@ export function filterReducer(state: FilterState, action: FilterAction): FilterS
return { ...state, names: action.names };
case 'set-billing-type':
return { ...state, billingType: action.billingType };
case 'set-period':
return { ...state, period: action.period };
case 'toggle-forecast':
return { ...state, showForecast: !state.showForecast };
case 'reset':
@ -59,5 +66,6 @@ export function filtersToQuery(state: FilterState): Record<string, string | unde
department: state.departments.length ? state.departments.join(',') : undefined,
name: state.names.length ? state.names.join(',') : undefined,
billing_type: state.billingType ?? undefined,
period: state.period,
};
}

View file

@ -1,7 +1,10 @@
import { useEffect, useMemo, useReducer, useState } from 'react';
import { Download } from 'lucide-react';
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { Download, RefreshCw } from 'lucide-react';
import FilterBar from '../components/FilterBar';
import UploadButton from '../components/UploadButton';
import KpiTiles from '../components/KpiTiles';
import PeriodToggle from '../components/PeriodToggle';
import HourBreakdown from '../components/HourBreakdown';
import MonthlyUtilisation from '../components/charts/MonthlyUtilisation';
import BookingVsActual from '../components/charts/BookingVsActual';
import BillabilityBreakdown from '../components/charts/BillabilityBreakdown';
@ -14,49 +17,65 @@ import { filterReducer, filtersToQuery, initialFilterState } from '../lib/filter
import { downloadCsv, rowsToCsv } from '../lib/csv';
import * as api from '../api/endpoints';
import { ApiError } from '../api/client';
import type { UtilisationSummaryRow } from '../api/types';
import type { UtilisationSummaryRow, UtilisationTotals } from '../api/types';
export default function Department() {
const airtable = useAirtableData(false);
const tl = useTimelog();
const [filters, dispatch] = useReducer(filterReducer, initialFilterState);
const [summary, setSummary] = useState<UtilisationSummaryRow[]>([]);
const [totals, setTotals] = useState<UtilisationTotals | null>(null);
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryError, setSummaryError] = useState<string | null>(null);
const [selectedPeriod, setSelectedPeriod] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const names = useMemo(
() => airtable.resources.map((r) => r.name).sort((a, b) => a.localeCompare(b)),
[airtable.resources],
);
const loadSummary = useMemo(
() => async () => {
setSummaryLoading(true);
setSummaryError(null);
try {
const q = filtersToQuery(filters);
const res = await api.getUtilisationSummary({
from: q.from,
to: q.to,
department: q.department,
name: q.name,
billing_type: q.billing_type,
timelogHash: tl.hash ?? undefined,
});
setSummary(res.rows);
} catch (err) {
setSummaryError(err instanceof ApiError ? err.detail : (err as Error).message);
} finally {
setSummaryLoading(false);
}
},
[filters, tl.hash],
);
const loadSummary = useCallback(async () => {
setSummaryLoading(true);
setSummaryError(null);
try {
const q = filtersToQuery(filters);
const res = await api.getUtilisationSummary({
from: q.from,
to: q.to,
department: q.department,
name: q.name,
billing_type: q.billing_type,
period: filters.period,
timelogHash: tl.hash ?? undefined,
});
setSummary(res.rows);
setTotals(res.totals ?? null);
} catch (err) {
setSummaryError(err instanceof ApiError ? err.detail : (err as Error).message);
} finally {
setSummaryLoading(false);
}
}, [filters, tl.hash]);
useEffect(() => {
void loadSummary();
}, [loadSummary]);
const handleSync = useCallback(async () => {
setSyncing(true);
try {
// Force-refresh bookings cache, then re-derive summary.
const q = filtersToQuery(filters);
await api.getBookings({ from: q.from, to: q.to, refresh: true });
await loadSummary();
} catch (err) {
setSummaryError(err instanceof ApiError ? err.detail : (err as Error).message);
} finally {
setSyncing(false);
}
}, [filters, loadSummary]);
const handleExport = () => {
if (summary.length === 0) return;
const csv = rowsToCsv(summary as unknown as Record<string, unknown>[]);
@ -71,6 +90,7 @@ export default function Department() {
<li>Upload your timelog export (.xlsx or .csv). It stays in this session only.</li>
<li>Pick a date preset (or custom range) and narrow by department or name.</li>
<li>The charts below recompute automatically. Toggle the forecast line to compare scenarios.</li>
<li>Click a bar to drill into a per-project Hour Breakdown.</li>
<li>Use <em>Export CSV</em> to share the summary outside the app.</li>
</ol>
</section>
@ -95,16 +115,35 @@ export default function Department() {
/>
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={handleExport}
disabled={summary.length === 0}
className="btn-secondary"
data-tutorial-id="export-csv"
>
<Download className="h-4 w-4" aria-hidden /> Export CSV
</button>
<div className="flex flex-wrap items-center justify-between gap-2">
<PeriodToggle
value={filters.period}
onChange={(p) => {
dispatch({ type: 'set-period', period: p });
// Clear drill-down: period labels won't match across week/month.
setSelectedPeriod(null);
}}
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => void handleSync()}
disabled={syncing || summaryLoading}
className="btn-secondary"
data-tutorial-id="sync-airtable"
>
<RefreshCw className={`h-4 w-4 ${syncing ? 'animate-spin' : ''}`} aria-hidden /> Sync Airtable
</button>
<button
type="button"
onClick={handleExport}
disabled={summary.length === 0}
className="btn-secondary"
data-tutorial-id="export-csv"
>
<Download className="h-4 w-4" aria-hidden /> Export CSV
</button>
</div>
</div>
{airtable.error && <ErrorBox message={airtable.error} onRetry={airtable.refresh} />}
@ -112,17 +151,39 @@ export default function Department() {
{(airtable.loading || summaryLoading) && <Loading label="Crunching numbers…" />}
{!summaryError && summary.length > 0 && (
{!summaryError && (
<>
<ErrorBoundary label="Monthly Utilisation">
<MonthlyUtilisation rows={summary} showForecast={filters.showForecast} />
</ErrorBoundary>
<ErrorBoundary label="Booking vs Actual">
<BookingVsActual rows={summary} />
</ErrorBoundary>
<ErrorBoundary label="Billability Breakdown">
<BillabilityBreakdown rows={summary} />
<ErrorBoundary label="KPI Tiles">
<KpiTiles totals={totals} period={filters.period} />
</ErrorBoundary>
{summary.length > 0 && (
<>
<ErrorBoundary label="Monthly Utilisation">
<MonthlyUtilisation
rows={summary}
showForecast={filters.showForecast}
onPeriodClick={setSelectedPeriod}
/>
</ErrorBoundary>
{selectedPeriod && (
<ErrorBoundary label="Hour Breakdown">
<HourBreakdown
period={selectedPeriod}
from={filters.range?.from}
to={filters.range?.to}
timelogHash={tl.hash}
onClose={() => setSelectedPeriod(null)}
/>
</ErrorBoundary>
)}
<ErrorBoundary label="Booking vs Actual">
<BookingVsActual rows={summary} />
</ErrorBoundary>
<ErrorBoundary label="Billability Breakdown">
<BillabilityBreakdown rows={summary} />
</ErrorBoundary>
</>
)}
</>
)}
</div>

View file

@ -1,6 +1,9 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { Download } from 'lucide-react';
import { Download, RefreshCw } from 'lucide-react';
import FilterBar from '../components/FilterBar';
import KpiTiles from '../components/KpiTiles';
import PeriodToggle from '../components/PeriodToggle';
import HourBreakdown from '../components/HourBreakdown';
import WeeklyUtilisation from '../components/charts/WeeklyUtilisation';
import ProjectLoadPerPerson from '../components/charts/ProjectLoadPerPerson';
import FTEvsFreelancer from '../components/charts/FTEvsFreelancer';
@ -8,18 +11,22 @@ import Loading from '../components/Loading';
import ErrorBox from '../components/ErrorBox';
import ErrorBoundary from '../components/ErrorBoundary';
import { useAirtableData } from '../hooks/useAirtableData';
import { useTimelog } from '../hooks/useTimelog';
import { filterReducer, filtersToQuery, initialFilterState } from '../lib/filters';
import { downloadCsv, rowsToCsv } from '../lib/csv';
import * as api from '../api/endpoints';
import { ApiError } from '../api/client';
import type { Booking, UtilisationSummaryRow } from '../api/types';
import type { Booking, UtilisationSummaryRow, UtilisationTotals } from '../api/types';
export default function Resourcing() {
const airtable = useAirtableData(false);
const tl = useTimelog();
const [filters, dispatch] = useReducer(filterReducer, initialFilterState);
const [bookings, setBookings] = useState<Booking[]>([]);
const [summary, setSummary] = useState<UtilisationSummaryRow[]>([]);
const [totals, setTotals] = useState<UtilisationTotals | null>(null);
const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedPeriod, setSelectedPeriod] = useState<string | null>(null);
@ -42,39 +49,55 @@ export default function Resourcing() {
});
}, [bookings, filters.departments, filters.names]);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const q = filtersToQuery(filters);
const [bk, sm] = await Promise.all([
api.getBookings({
from: q.from,
to: q.to,
department: q.department,
name: q.name,
}),
api.getUtilisationSummary({
from: q.from,
to: q.to,
department: q.department,
name: q.name,
billing_type: q.billing_type,
}),
]);
setBookings(bk.bookings);
setSummary(sm.rows);
} catch (err) {
setError(err instanceof ApiError ? err.detail : (err as Error).message);
} finally {
setLoading(false);
}
}, [filters]);
const load = useCallback(
async (refresh = false) => {
setLoading(true);
setError(null);
try {
const q = filtersToQuery(filters);
const [bk, sm] = await Promise.all([
api.getBookings({
from: q.from,
to: q.to,
department: q.department,
name: q.name,
refresh,
}),
api.getUtilisationSummary({
from: q.from,
to: q.to,
department: q.department,
name: q.name,
billing_type: q.billing_type,
period: filters.period,
timelogHash: tl.hash ?? undefined,
}),
]);
setBookings(bk.bookings);
setSummary(sm.rows);
setTotals(sm.totals ?? null);
} catch (err) {
setError(err instanceof ApiError ? err.detail : (err as Error).message);
} finally {
setLoading(false);
}
},
[filters, tl.hash],
);
useEffect(() => {
void load();
}, [load]);
const handleSync = useCallback(async () => {
setSyncing(true);
try {
await load(true);
} finally {
setSyncing(false);
}
}, [load]);
const handleExport = () => {
if (summary.length === 0) return;
const csv = rowsToCsv(summary as unknown as Record<string, unknown>[]);
@ -92,36 +115,70 @@ export default function Resourcing() {
showForecastToggle={false}
/>
<div className="flex items-center justify-between">
{selectedPeriod ? (
<div className="text-sm text-slate-600">
Drilled into period <strong>{selectedPeriod}</strong>{' '}
<button onClick={() => setSelectedPeriod(null)} className="ml-2 text-blue-600 hover:underline">
clear
</button>
</div>
) : (
<span />
)}
<button
type="button"
onClick={handleExport}
disabled={summary.length === 0}
className="btn-secondary"
data-tutorial-id="export-csv"
>
<Download className="h-4 w-4" aria-hidden /> Export CSV
</button>
<div className="flex flex-wrap items-center justify-between gap-2">
<PeriodToggle
value={filters.period}
onChange={(p) => {
dispatch({ type: 'set-period', period: p });
setSelectedPeriod(null);
}}
/>
<div className="flex items-center gap-2">
{selectedPeriod && (
<div className="text-sm text-slate-600">
Drilled into <strong>{selectedPeriod}</strong>{' '}
<button onClick={() => setSelectedPeriod(null)} className="ml-1 text-blue-600 hover:underline">
clear
</button>
</div>
)}
<button
type="button"
onClick={() => void handleSync()}
disabled={syncing || loading}
className="btn-secondary"
data-tutorial-id="sync-airtable"
>
<RefreshCw className={`h-4 w-4 ${syncing ? 'animate-spin' : ''}`} aria-hidden /> Sync Airtable
</button>
<button
type="button"
onClick={handleExport}
disabled={summary.length === 0}
className="btn-secondary"
data-tutorial-id="export-csv"
>
<Download className="h-4 w-4" aria-hidden /> Export CSV
</button>
</div>
</div>
{error && <ErrorBox message={error} onRetry={load} />}
{error && <ErrorBox message={error} onRetry={() => load(false)} />}
{loading && <Loading label="Loading resourcing data…" />}
{!error && (
<>
<ErrorBoundary label="Weekly Utilisation">
<WeeklyUtilisation rows={summary} onPeriodClick={setSelectedPeriod} />
<ErrorBoundary label="KPI Tiles">
<KpiTiles totals={totals} period={filters.period} />
</ErrorBoundary>
<ErrorBoundary label="Weekly Utilisation">
<WeeklyUtilisation
rows={summary}
onPeriodClick={setSelectedPeriod}
period={filters.period}
/>
</ErrorBoundary>
{selectedPeriod && (
<ErrorBoundary label="Hour Breakdown">
<HourBreakdown
period={selectedPeriod}
from={filters.range?.from}
to={filters.range?.to}
timelogHash={tl.hash}
onClose={() => setSelectedPeriod(null)}
/>
</ErrorBoundary>
)}
<ErrorBoundary label="Project Load per Person">
<ProjectLoadPerPerson bookings={visibleBookings} />
</ErrorBoundary>

View file

@ -28,10 +28,14 @@ export default function Tutorial() {
return (
<div className="space-y-4">
<section className="card">
<h1 className="text-lg font-semibold text-slate-900">Tutorial</h1>
<h1 className="text-lg font-semibold text-slate-900">Tutorial Interactive Walkthrough</h1>
<p className="mt-1 text-sm text-slate-600">
A walkthrough of every tab. Click <em>Replay</em> to relaunch the guided tour for that section it only
highlights elements that are currently visible, so navigate to the matching tab first.
A walkthrough of every tab. Use the chapter buttons to replay any section the overlay only highlights
elements that are currently visible, so navigate to the matching tab first, then click <em>Replay</em>.
</p>
<p className="mt-2 text-xs text-slate-500">
Note: the original Video Walkthrough has been retired; the interactive tour below covers the same ground.
Jump to any step using the in-overlay arrow controls.
</p>
</section>
@ -50,7 +54,7 @@ export default function Tutorial() {
</button>
<ul className="mt-3 list-disc space-y-1 pl-5 text-xs text-slate-500">
{allSteps[s.key].map((step) => (
<li key={step.selector}>
<li key={`${s.key}-${step.selector}-${step.title}`}>
<strong className="text-slate-700">{step.title}:</strong> {step.description}
</li>
))}