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 -29
View File
@@ -39,36 +39,50 @@
{% endif %}
</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 -->
{% if p.description %}
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">{{ p.description }}</p>
{% endif %}
<!-- Stats -->
<div class="grid grid-cols-3 gap-3 pt-4 border-t border-gray-100 dark:border-gray-700/60">
<!-- Per-module stats — each module shows only its own, relevant counts.
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>
<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>
@@ -77,11 +91,8 @@
<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>
</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>
{% endif %}
</a>
<!-- Per-module quick-open footer — jumps straight into that module's tab -->