feat(projects): per-module stat breakdown on project cards

The single Locations/Units/Active row was confusing: "Active" collided with
the green Active status badge and actually meant sound recording sessions, so
vibration-only projects showed a meaningless "Active 0", and combined projects
lumped both modules together with no split.

Cards now show one stat line per module, each carrying its own identity +
status badge (so the separate chip row is dropped as redundant):
  Vibration   N locations · M units
  Sound       N NRLs · M units · K recording

- /list endpoint computes module_stats: locations (active, by type) and units
  counted via a join on the assigned location's type — so a module's unit
  count always reconciles with its location count (verified: sound+vibration
  units == total active assignments for every project).
- "recording" (active sessions) shows only under Sound, where it's meaningful.
- Projects with no modules fall back to a simple Locations/Units row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015m9FuJvk65kJmmP3c9c6r1
This commit is contained in:
2026-06-22 20:38:48 +00:00
parent f54c62b332
commit 80464c6f11
2 changed files with 80 additions and 32 deletions
+40 -3
View File
@@ -470,8 +470,10 @@ async def get_projects_list(
for project in projects:
# Get project type
project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first()
mods = _get_project_modules(project.id, db)
# Count locations
# Count locations (project-wide, includes removed — kept for back-compat
# with the compact list view).
location_count = db.query(func.count(MonitoringLocation.id)).filter_by(
project_id=project.id
).scalar()
@@ -484,7 +486,7 @@ async def get_projects_list(
)
).scalar()
# Count active sessions
# Count active (recording) sessions — a sound-monitoring concept
active_session_count = db.query(func.count(MonitoringSession.id)).filter(
and_(
MonitoringSession.project_id == project.id,
@@ -492,11 +494,46 @@ async def get_projects_list(
)
).scalar()
# Per-module stats — each module shows only its own, relevant counts.
# Locations: active only (removed_at IS NULL). Units: active assignments
# counted by the TYPE OF LOCATION they sit on (join), so a module's unit
# count always lines up with its own location count — independent of the
# assignment's denormalized device_type.
def _module_loc_count(loc_type):
return db.query(func.count(MonitoringLocation.id)).filter(
MonitoringLocation.project_id == project.id,
MonitoringLocation.location_type == loc_type,
MonitoringLocation.removed_at.is_(None),
).scalar()
def _module_unit_count(loc_type):
return db.query(func.count(UnitAssignment.id)).join(
MonitoringLocation, MonitoringLocation.id == UnitAssignment.location_id
).filter(
UnitAssignment.project_id == project.id,
UnitAssignment.assigned_until.is_(None),
MonitoringLocation.location_type == loc_type,
).scalar()
module_stats = {}
if "vibration_monitoring" in mods:
module_stats["vibration"] = {
"locations": _module_loc_count("vibration"),
"units": _module_unit_count("vibration"),
}
if "sound_monitoring" in mods:
module_stats["sound"] = {
"locations": _module_loc_count("sound"),
"units": _module_unit_count("sound"),
"recording": active_session_count,
}
projects_data.append({
"project": project,
"project_type": project_type,
"modules": _get_project_modules(project.id, db),
"modules": mods,
"module_status": _get_module_statuses(project.id, db),
"module_stats": module_stats,
"location_count": location_count,
"unit_count": unit_count,
"active_session_count": active_session_count,