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:
@@ -470,8 +470,10 @@ async def get_projects_list(
|
|||||||
for project in projects:
|
for project in projects:
|
||||||
# Get project type
|
# Get project type
|
||||||
project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first()
|
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(
|
location_count = db.query(func.count(MonitoringLocation.id)).filter_by(
|
||||||
project_id=project.id
|
project_id=project.id
|
||||||
).scalar()
|
).scalar()
|
||||||
@@ -484,7 +486,7 @@ async def get_projects_list(
|
|||||||
)
|
)
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
# Count active sessions
|
# Count active (recording) sessions — a sound-monitoring concept
|
||||||
active_session_count = db.query(func.count(MonitoringSession.id)).filter(
|
active_session_count = db.query(func.count(MonitoringSession.id)).filter(
|
||||||
and_(
|
and_(
|
||||||
MonitoringSession.project_id == project.id,
|
MonitoringSession.project_id == project.id,
|
||||||
@@ -492,11 +494,46 @@ async def get_projects_list(
|
|||||||
)
|
)
|
||||||
).scalar()
|
).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({
|
projects_data.append({
|
||||||
"project": project,
|
"project": project,
|
||||||
"project_type": project_type,
|
"project_type": project_type,
|
||||||
"modules": _get_project_modules(project.id, db),
|
"modules": mods,
|
||||||
"module_status": _get_module_statuses(project.id, db),
|
"module_status": _get_module_statuses(project.id, db),
|
||||||
|
"module_stats": module_stats,
|
||||||
"location_count": location_count,
|
"location_count": location_count,
|
||||||
"unit_count": unit_count,
|
"unit_count": unit_count,
|
||||||
"active_session_count": active_session_count,
|
"active_session_count": active_session_count,
|
||||||
|
|||||||
@@ -39,36 +39,50 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Module chips (with per-module status) -->
|
|
||||||
{% if mods %}
|
|
||||||
<div class="flex flex-wrap items-center gap-1.5 mb-3">
|
|
||||||
{% for m in mods %}
|
|
||||||
{% set st = mstatus.get(m, 'active') %}
|
|
||||||
<span class="inline-flex items-center gap-1 pl-2 pr-2 py-0.5 rounded-full text-[11px] font-medium
|
|
||||||
{% if m == 'sound_monitoring' %}bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300
|
|
||||||
{% elif m == 'vibration_monitoring' %}bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300
|
|
||||||
{% else %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
|
||||||
{% if m == 'sound_monitoring' %}
|
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6v12M9 8.464a5 5 0 000 7.072"/></svg>
|
|
||||||
Sound
|
|
||||||
{% elif m == 'vibration_monitoring' %}
|
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
|
||||||
Vibration
|
|
||||||
{% else %}{{ m }}{% endif %}
|
|
||||||
{% if st == 'completed' %}<span class="text-blue-600 dark:text-blue-300" title="Completed">✓</span>
|
|
||||||
{% elif st == 'on_hold' %}<span class="opacity-70" title="On hold">⏸</span>{% endif %}
|
|
||||||
</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
{% if p.description %}
|
{% if p.description %}
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">{{ p.description }}</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">{{ p.description }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Per-module stats — each module shows only its own, relevant counts.
|
||||||
<div class="grid grid-cols-3 gap-3 pt-4 border-t border-gray-100 dark:border-gray-700/60">
|
These lines double as the module legend (identity + status), so a
|
||||||
|
separate chip row would be redundant. -->
|
||||||
|
{% set ms = item.module_stats %}
|
||||||
|
{% if ms %}
|
||||||
|
<div class="space-y-2.5 pt-4 border-t border-gray-100 dark:border-gray-700/60">
|
||||||
|
{% if 'vibration' in ms %}
|
||||||
|
{% set vst = mstatus.get('vibration_monitoring', 'active') %}
|
||||||
|
<div class="flex items-baseline gap-2.5 text-sm flex-wrap">
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||||
|
Vibration
|
||||||
|
{% if vst == 'completed' %}<span title="Completed">✓</span>{% elif vst == 'on_hold' %}<span class="opacity-80" title="On hold">⏸</span>{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">
|
||||||
|
<b class="font-semibold text-gray-900 dark:text-white tabular-nums">{{ ms.vibration.locations }}</b> location{{ '' if ms.vibration.locations == 1 else 's' }}
|
||||||
|
· <b class="font-semibold text-gray-900 dark:text-white tabular-nums">{{ ms.vibration.units }}</b> unit{{ '' if ms.vibration.units == 1 else 's' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if 'sound' in ms %}
|
||||||
|
{% set sst = mstatus.get('sound_monitoring', 'active') %}
|
||||||
|
<div class="flex items-baseline gap-2.5 text-sm flex-wrap">
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6v12M9 8.464a5 5 0 000 7.072"/></svg>
|
||||||
|
Sound
|
||||||
|
{% if sst == 'completed' %}<span title="Completed">✓</span>{% elif sst == 'on_hold' %}<span class="opacity-80" title="On hold">⏸</span>{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">
|
||||||
|
<b class="font-semibold text-gray-900 dark:text-white tabular-nums">{{ ms.sound.locations }}</b> NRL{{ '' if ms.sound.locations == 1 else 's' }}
|
||||||
|
· <b class="font-semibold text-gray-900 dark:text-white tabular-nums">{{ ms.sound.units }}</b> unit{{ '' if ms.sound.units == 1 else 's' }}
|
||||||
|
· {% if ms.sound.recording > 0 %}<b class="font-semibold text-green-600 dark:text-green-400 tabular-nums">{{ ms.sound.recording }}</b> recording{% else %}<span class="text-gray-400 dark:text-gray-500">0 recording</span>{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Fallback: project with no modules enabled yet -->
|
||||||
|
<div class="grid grid-cols-2 gap-3 pt-4 border-t border-gray-100 dark:border-gray-700/60">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[11px] uppercase tracking-wide text-gray-400 dark:text-gray-500">Locations</p>
|
<p class="text-[11px] uppercase tracking-wide text-gray-400 dark:text-gray-500">Locations</p>
|
||||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ item.location_count }}</p>
|
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ item.location_count }}</p>
|
||||||
@@ -77,11 +91,8 @@
|
|||||||
<p class="text-[11px] uppercase tracking-wide text-gray-400 dark:text-gray-500">Units</p>
|
<p class="text-[11px] uppercase tracking-wide text-gray-400 dark:text-gray-500">Units</p>
|
||||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ item.unit_count }}</p>
|
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ item.unit_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p class="text-[11px] uppercase tracking-wide text-gray-400 dark:text-gray-500">Active</p>
|
|
||||||
<p class="text-lg font-semibold {% if item.active_session_count > 0 %}text-green-600 dark:text-green-400{% else %}text-gray-900 dark:text-white{% endif %}">{{ item.active_session_count }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Per-module quick-open footer — jumps straight into that module's tab -->
|
<!-- Per-module quick-open footer — jumps straight into that module's tab -->
|
||||||
|
|||||||
Reference in New Issue
Block a user