Add Jitsi reports to IDAA

This commit is contained in:
Scott Idem
2026-05-05 14:02:52 -04:00
parent 146682a30b
commit 0b04ce7c0c
3 changed files with 737 additions and 225 deletions

View File

@@ -85,8 +85,8 @@ src/routes/idaa/
│ │ └── [event_id]/
│ │ ├── +page.svelte # Meeting detail page — renders view OR edit based on session flag
│ │ └── +page.ts
── video_conferences/ # Jitsi video conference integration
└── jitsi_reports/ # External Jitsi reporting
── video_conferences/ # Jitsi video conference integration
└── jitsi_reports/ # Jitsi meeting activity log report (trusted_access only)
```
> **Note:** Recovery Meetings has **two UI entry points**:
@@ -183,6 +183,12 @@ fetch(`${api_root_url}/customers/${uuid}`, {
- **`novi_bb_base_url`**: (optional) Base URL used to build links for Bulletin Board notification emails.
- **`jitsi_exclude_names`**: (optional) Array of display name strings to exclude from Jitsi Reports.
Used to hide known staff and test accounts from the activity report so participant counts and lists
reflect real member activity only. Names are matched case-insensitively against `final_participants[].displayName`.
Example: `["Scott I.", "Michelle V.", "Brie K."]`. The filter is applied before the "Real meetings only"
threshold check — a session with only excluded participants is treated as having 0 real participants.
- **Email config values** (`noreply_email`, `noreply_name`, `admin_email`, `admin_name`): used by functions that send notification emails (BB posts, comments, recovery meetings).
### Stores / runtime fields set by verification
@@ -446,6 +452,94 @@ Moderation permissions are controlled by `novi_jitsi_mod_li` in the IDAA store.
---
## Module 5: Jitsi Reports
**Route:** `/idaa/jitsi_reports/`
**Access:** `trusted_access` or `novi_verified` — same gate as the rest of `(idaa)/`
**Data source:** `activity_log` table — `jitsi_meeting_event` and `jitsi_meeting_stats` log types
**Library function:** `qry__jitsi_report()` in `src/lib/ae_reports/reports_functions.ts`
An admin/staff reporting tool that aggregates raw Jitsi activity logs into human-readable meeting sessions. It is **not** a member-facing page — IDAA members do not see it.
### View Modes
Two display modes, toggled via a button in the page header:
| Mode | Description |
| --- | --- |
| **Grouped by Room** (default) | One collapsible section per `room_name`. Each section contains a compact table: Date / Time / Duration / Attendees / Participant List. Mirrors the output of the offline Python script (`create_jitsi_report.py`). |
| **Flat List** | Original card-per-session accordion layout. Better for drilling into event timelines and raw participant lists. |
Both modes use the same filtered data set — switching views does not reset filters.
### Filters
| Filter | Default | Logic |
| --- | --- | --- |
| **Real meetings only** | off | Show only sessions where `real_participant_count >= 2` OR `duration > 5 min`. Applied **after** staff exclusion (see below). |
| **Min. Participants** | 0 | Minimum `real_participant_count` to display a session. |
| **Room Name** | (empty) | Case-insensitive substring match against `room_name`. |
| **From / To** | (empty) | Date range applied to `start_time`. "To" date includes the full end of day. |
A "Reset Filters" button appears whenever any filter is non-default.
### Staff / Test Exclusion
**Problem:** Staff and test accounts (Scott, Michelle, Brie) join real member meetings for setup, testing, and tech support. Their presence inflates participant counts and pollutes the participant list.
**Solution:** A configurable exclusion list in `site_cfg_json`:
```json
{ "jitsi_exclude_names": ["Scott I.", "Michelle V.", "Brie K."] }
```
**How it works (client-side only, no backend change needed):**
1. On load, the page reads `$ae_loc.site_cfg_json?.jitsi_exclude_names` (string array, defaults to `[]`).
2. For every session, a `real_participants` derived list is computed by filtering `final_participants` against the exclusion list (case-insensitive display name match).
3. `real_participant_count = real_participants.length` — this count drives all filters, stats, and the participant list column in grouped view.
4. The raw `final_participant_count` from the API is never shown to the user once an exclusion list is configured.
**Why display-name matching (not Novi UUID):** Jitsi participant data (`meta_json.participants`) only contains `displayName` and `role` — the Novi UUID is not passed through to the activity log. UUID-based exclusion would require a Jitsi config change plus a backend schema update and is deferred. Display names for OSIT staff are stable and controlled.
### Summary Stats
Shown above the meeting list when data is loaded. Stats reflect the **filtered + exclusion-applied** view:
- **Meetings Shown** — count of sessions passing all filters
- **Total Participants** — sum of `real_participant_count` across all shown sessions
- **Avg Duration** — mean session duration (HH:MM:SS)
- **Total Duration** — sum of all session durations (HH:MM:SS)
In grouped view, each room header also shows its own subtotals (meeting count, unique participants).
### Jitsi URL Builder
Collapsible panel, visible to `trusted_access` users only. Generates properly-formatted Jitsi meeting URLs for IDAA rooms. Component: `ae_idaa_comp__jitsi_url_builder.svelte`.
### Export
CSV and JSON export buttons in the page header export the **currently filtered + exclusion-applied** data set.
### Room Name Fragmentation
The same logical meeting can appear as multiple rooms (e.g. `IDAA-BIPOC-Meeting`, `IDAA-BIPOC-Meeting-2026`, `IDAA-BIPOC-Meeting-March-31`) because the Jitsi URL builder appends a date suffix to generate unique per-session room names. In grouped view, these appear as separate groups. A future normalization pass (strip trailing date suffixes) could optionally merge them — not implemented yet.
### Data Flow
```text
activity_log table
└── qry__jitsi_report() # reports_functions.ts — fetches + aggregates by meeting_id
└── MeetingReport[] # { meeting_id, room_name, start_time, final_duration,
# final_participants, final_participant_count, events }
└── jitsi_reports/+page.svelte
├── apply exclusion list → real_participants / real_participant_count
├── apply filters → meetings_filtered
├── derive grouped view → Map<room_name, MeetingReport[]>
└── render flat or grouped
```
---
## State Management (`ae_idaa_stores.ts`)
Four stores manage all IDAA state:
@@ -582,6 +676,7 @@ ae_loc.idaa_loc = { novi_uuid: 'test-uuid-value', ... };
| Bulletin Board | ❌ None | Priority — most sensitive module |
| Recovery Meetings | ✅ Substantial | `tests/idaa_recovery_meeting_edit.test.ts` — form render, field interactions, PATCH payload verification (all sections), real backend save, creation linkage (Novi UUID in POST body) |
| Video Conferences | ❌ None | Jitsi complexity, lower priority |
| Jitsi Reports | ❌ None | Admin-only tool; lower privacy risk than member modules |
**Pending:** BB Post and Post Comment creation linkage tests (pattern established in Recovery Meetings test).
@@ -634,4 +729,4 @@ ae_loc.idaa_loc = { novi_uuid: 'test-uuid-value', ... };
---
**Document Status:** ✅ Current
**Last Verified:** 2026-04-07updated for Novi UUID triple-linkage enforcement, staff editing rules, Contact 1 convention, test coverage
**Last Verified:** 2026-05-05added Module 5: Jitsi Reports (grouped view, real-meetings filter, staff exclusion via `jitsi_exclude_names`); fixed route tree (`jitsi_reports/` is inside `(idaa)/`)

View File

@@ -7,6 +7,23 @@ import {
import { db_save_ae_obj_li__ae_obj } from '$lib/ae_core/core__idb_dexie';
import { db_core } from '$lib/ae_core/db_core';
// MariaDB TEXT columns come back as JSON strings from the API — parse safely.
function safe_parse_meta(raw: unknown): Record<string, unknown> {
if (!raw) return {};
if (typeof raw === 'object') return raw as Record<string, unknown>;
try {
return JSON.parse(raw as string) as Record<string, unknown>;
} catch {
return {};
}
}
function parse_duration_seconds(d: string): number {
if (!d || !d.includes(':')) return 0;
const [h, m, s] = d.split(':').map(Number);
return (h || 0) * 3600 + (m || 0) * 60 + (s || 0);
}
/**
* @description Queries all Jitsi-related activity logs and processes them into a structured report,
* grouped by meeting ID.
@@ -96,50 +113,121 @@ export async function qry__jitsi_report({
}
// Step 2: Process the flat list into a structured report.
//
// Participants come from two sources — both are needed for a complete list:
// 1. jitsi_meeting_participant_joined events: meta.full_name (all who ever joined)
// 2. jitsi_meeting_init / stats / end snapshots: meta.participants[].displayName+role
// Source 2 has role info; source 1 may catch participants who left before the snapshot.
// We merge both into a Map<displayName, role> per meeting, deduplicating by name.
//
// Duration: take the MAX across all init/stats/end events — periodic stats may
// have a higher value than the final init summary in some Jitsi configurations.
const meetings = new Map<string, any>();
// Per-meeting participant map: displayName → role
const participant_maps = new Map<string, Map<string, string>>();
// Per-meeting max observed duration in seconds
const max_duration_secs = new Map<string, number>();
for (const log of flat_log_list) {
const meeting_id = log.external_client_id;
if (!meeting_id) continue;
if (!log.name?.startsWith('jitsi_')) continue;
// Make sure the name field is prefixed with "jitsi_"
if (!log.name.startsWith('jitsi_')) continue;
// Ensure a base entry for the meeting exists
if (!meetings.has(meeting_id)) {
meetings.set(meeting_id, {
meeting_id: meeting_id,
meeting_id,
room_name: 'Unknown',
start_time: log.created_on, // Fallback start time
start_time: log.created_on,
final_duration: '00:00:00',
final_participants: [],
final_participant_count: 0,
events: []
});
participant_maps.set(meeting_id, new Map());
max_duration_secs.set(meeting_id, 0);
}
const meeting_report = meetings.get(meeting_id);
const p_map = participant_maps.get(meeting_id)!;
const meta = safe_parse_meta(log.meta_json);
if (log.action === 'jitsi_meeting_init') {
// This is the main log entry, containing the final state.
meeting_report.room_name = log.description;
meeting_report.start_time = log.created_on; // The init log has the true start time
if (log.meta_json) {
meeting_report.final_duration = log.meta_json.duration;
meeting_report.final_participants = log.meta_json.participants;
meeting_report.final_participant_count =
log.meta_json.participant_count;
// Strip "Event in room: " prefix Jitsi sometimes prepends to the description
meeting_report.room_name =
(log.description ?? '').replace(/^Event in room:\s*/i, '').trim() ||
'Unknown';
meeting_report.start_time = log.created_on;
}
// Parse duration from init, stats, or end — keep the maximum seen
if (
log.action === 'jitsi_meeting_init' ||
log.action === 'jitsi_meeting_stats' ||
log.action === 'jitsi_meeting_end'
) {
const dur_str = ((meta.duration ?? meta.final_duration) as string) || '';
if (dur_str) {
const secs = parse_duration_seconds(dur_str);
if (secs > (max_duration_secs.get(meeting_id) ?? 0)) {
max_duration_secs.set(meeting_id, secs);
meeting_report.final_duration = dur_str;
}
}
} else {
// This is a discrete event log.
// Merge snapshot participant list (has role info — preferred source)
const snapshot = meta.participants as
| Array<{ displayName?: string; role?: string }>
| undefined;
if (Array.isArray(snapshot)) {
for (const p of snapshot) {
if (!p.displayName) continue;
// Only overwrite an existing entry if we're upgrading from participant → moderator
const existing_role = p_map.get(p.displayName);
if (!existing_role || (existing_role !== 'moderator' && p.role === 'moderator')) {
p_map.set(p.displayName, p.role ?? 'participant');
}
}
}
}
// Collect participants from join events (may catch people who left before the snapshot)
if (log.action === 'jitsi_meeting_participant_joined') {
const full_name = (meta.full_name as string) || '';
if (full_name && !p_map.has(full_name)) {
p_map.set(full_name, (meta.role as string) ?? 'participant');
}
}
// Discrete events for the timeline (all non-init actions)
if (log.action !== 'jitsi_meeting_init') {
meeting_report.events.push({
timestamp: log.created_on,
action: log.action,
details: log.meta_json
details: {
full_name: (meta.full_name as string) ?? undefined
}
});
}
}
// Compile final participant lists from the deduplicated maps
for (const [meeting_id, p_map] of participant_maps) {
const meeting_report = meetings.get(meeting_id);
if (!meeting_report) continue;
if (p_map.size > 0) {
// Sort: moderators first, then alphabetically
meeting_report.final_participants = Array.from(p_map.entries())
.map(([displayName, role]) => ({ displayName, role }))
.sort((a: any, b: any) => {
if (a.role === 'moderator' && b.role !== 'moderator') return -1;
if (a.role !== 'moderator' && b.role === 'moderator') return 1;
return a.displayName.localeCompare(b.displayName);
});
meeting_report.final_participant_count = p_map.size;
}
}
// Sort events within each meeting chronologically
for (const report of meetings.values()) {
report.events.sort(

View File

@@ -27,12 +27,16 @@ interface MeetingReport {
events: MeetingEvent[];
}
// Extends MeetingReport with exclusion-applied participant data
interface MeetingReportEnriched extends MeetingReport {
real_participants: MeetingParticipant[];
real_participant_count: number;
}
// --- Data state ---
// Resolve the streamed promise into reactive state so we can filter and export it.
let meetings_all = $state<MeetingReport[]>([]);
let meetings_loading = $state(true);
let meetings_error = $state<string | null>(null);
let meetings_load_started = $state(false);
$effect(() => {
@@ -65,33 +69,96 @@ $effect(() => {
});
});
// --- Staff/test exclusion ---
// Read display names to exclude from site_cfg_json.jitsi_exclude_names.
// Matched case-insensitively against MeetingParticipant.displayName.
let exclude_names = $derived<string[]>(
(($ae_loc.site_cfg_json as Record<string, unknown>)
?.jitsi_exclude_names as string[] | undefined)
?.map((n) => n.toLowerCase().trim()) ?? []
);
// Apply exclusion to every meeting — produces the "real" participant list
// used by all downstream filters, stats, and display.
let meetings_enriched = $derived<MeetingReportEnriched[]>(
meetings_all.map((m) => {
// final_participants can be null/undefined if meta_json.participants was null in the API response
const all_participants: MeetingParticipant[] = m.final_participants ?? [];
const real_participants =
exclude_names.length > 0
? all_participants.filter(
(p) =>
!exclude_names.includes(
p.displayName.toLowerCase().trim()
)
)
: all_participants;
return {
...m,
real_participants,
real_participant_count: real_participants.length
};
})
);
// --- View mode ---
let group_by_room = $state(true);
// --- Filter state ---
// Default 0 so historical logs (which lack an init record and have count=0) are visible.
// Once logging is fully established, this can be raised to 1 to hide empty/test meetings.
let filter_real_only = $state(false);
let filter_min_participants = $state(0);
let filter_room_name = $state('');
let filter_date_from = $state('');
let filter_date_to = $state('');
let filters_are_modified = $derived(
filter_min_participants !== 0 ||
filter_real_only ||
filter_min_participants !== 0 ||
filter_room_name !== '' ||
filter_date_from !== '' ||
filter_date_to !== ''
);
function reset_filters() {
filter_real_only = false;
filter_min_participants = 0;
filter_room_name = '';
filter_date_from = '';
filter_date_to = '';
}
// --- Duration helpers ---
function parse_duration_seconds(d: string): number {
if (!d) return 0;
const parts = d.split(':').map(Number);
return (parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0);
}
function format_seconds(total: number): string {
const h = Math.floor(total / 3600).toString().padStart(2, '0');
const m = Math.floor((total % 3600) / 60).toString().padStart(2, '0');
const s = Math.floor(total % 60).toString().padStart(2, '0');
return `${h}:${m}:${s}`;
}
function compute_end_time(start_time: string, duration: string): string {
if (!start_time || !duration) return '—';
const start = new Date(start_time);
const secs = parse_duration_seconds(duration);
if (secs === 0) return '—';
const end = new Date(start.getTime() + secs * 1000);
return end.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// --- Derived: filtered meetings ---
// All counts and thresholds use real_participant_count (post-exclusion).
let meetings_filtered = $derived.by(() => {
return meetings_all.filter((m) => {
if ((m.final_participant_count ?? 0) < filter_min_participants)
return false;
return meetings_enriched.filter((m) => {
if (filter_real_only) {
const dur_secs = parse_duration_seconds(m.final_duration);
if (m.real_participant_count < 2 && dur_secs <= 300) return false;
}
if (m.real_participant_count < filter_min_participants) return false;
if (
filter_room_name &&
!m.room_name?.toLowerCase().includes(filter_room_name.toLowerCase())
@@ -102,7 +169,6 @@ let meetings_filtered = $derived.by(() => {
return false;
}
if (filter_date_to) {
// Include full end-of-day by appending T23:59:59 to the date string
if (
Date.parse(m.start_time) >
Date.parse(filter_date_to + 'T23:59:59.999')
@@ -113,30 +179,36 @@ let meetings_filtered = $derived.by(() => {
});
});
// --- Derived: grouped by room ---
// Rooms sorted by most-recent session desc; sessions within each room sorted asc.
let meetings_grouped = $derived.by<Map<string, MeetingReportEnriched[]>>(() => {
const groups = new Map<string, MeetingReportEnriched[]>();
for (const m of meetings_filtered) {
const room = m.room_name ?? 'Unknown';
if (!groups.has(room)) groups.set(room, []);
groups.get(room)!.push(m);
}
for (const sessions of groups.values()) {
sessions.sort(
(a, b) =>
new Date(a.start_time).getTime() -
new Date(b.start_time).getTime()
);
}
return new Map(
[...groups.entries()].sort((a, b) => {
const a_last = a[1].at(-1)?.start_time ?? '';
const b_last = b[1].at(-1)?.start_time ?? '';
return new Date(b_last).getTime() - new Date(a_last).getTime();
})
);
});
// --- Summary stats ---
function parse_duration_seconds(d: string): number {
if (!d) return 0;
const parts = d.split(':').map(Number);
return (parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0);
}
function format_seconds(total: number): string {
const h = Math.floor(total / 3600)
.toString()
.padStart(2, '0');
const m = Math.floor((total % 3600) / 60)
.toString()
.padStart(2, '0');
const s = Math.floor(total % 60)
.toString()
.padStart(2, '0');
return `${h}:${m}:${s}`;
}
let summary = $derived.by(() => {
const count = meetings_filtered.length;
const total_participants = meetings_filtered.reduce(
(sum, m) => sum + (m.final_participant_count ?? 0),
(sum, m) => sum + m.real_participant_count,
0
);
const total_secs = meetings_filtered.reduce(
@@ -152,14 +224,41 @@ let summary = $derived.by(() => {
};
});
// --- Accordion state ---
let open_accordions = $state<{ [key: string]: boolean }>({});
let show_url_builder = $state(false);
function room_stats(sessions: MeetingReportEnriched[]) {
const total_secs = sessions.reduce(
(sum, m) => sum + parse_duration_seconds(m.final_duration),
0
);
const unique_names = new Set(
sessions.flatMap((m) => m.real_participants.map((p) => p.displayName))
);
return {
meeting_count: sessions.length,
total_duration: format_seconds(total_secs),
unique_participant_count: unique_names.size
};
}
// --- Accordion / room open state ---
let open_accordions = $state<Record<string, boolean>>({});
// Rooms default to open; only track explicitly-closed rooms.
let closed_rooms = $state<Record<string, boolean>>({});
function toggle_accordion(meeting_id: string) {
open_accordions[meeting_id] = !open_accordions[meeting_id];
}
function toggle_room(room_name: string) {
closed_rooms[room_name] = !closed_rooms[room_name];
}
function is_room_open(room_name: string): boolean {
return !closed_rooms[room_name];
}
// --- URL Builder ---
let show_url_builder = $state(false);
// --- Export ---
function download_file(content: string, filename: string, mime: string) {
const blob = new Blob([content], { type: mime });
@@ -180,7 +279,8 @@ function export_csv() {
'Room Name',
'Start Time',
'Duration',
'Participant Count'
'Participant Count',
'Participants'
]
];
for (const m of meetings_filtered) {
@@ -189,7 +289,8 @@ function export_csv() {
m.room_name ?? '',
m.start_time ? new Date(m.start_time).toISOString() : '',
m.final_duration ?? '',
String(m.final_participant_count ?? 0)
String(m.real_participant_count),
m.real_participants.map((p) => p.displayName).join('; ')
]);
}
const csv = rows
@@ -202,7 +303,15 @@ function export_csv() {
function export_json() {
download_file(
JSON.stringify(meetings_filtered, null, 2),
JSON.stringify(
meetings_filtered.map((m) => ({
...m,
final_participants: m.real_participants,
final_participant_count: m.real_participant_count
})),
null,
2
),
'jitsi_meeting_report.json',
'application/json'
);
@@ -214,10 +323,33 @@ function export_json() {
</svelte:head>
<div class="w-full max-w-5xl space-y-4 p-4">
<!-- Page header + export buttons -->
<!-- Page header: view toggle + export buttons -->
<div class="flex flex-row flex-wrap items-center justify-between gap-2">
<h1 class="text-xl font-bold">Jitsi Meeting Reports</h1>
<div class="flex gap-2">
<div class="flex items-center gap-2">
<!-- View mode toggle -->
<div class="border-surface-200-800 flex overflow-hidden rounded-lg border text-sm">
<button
type="button"
onclick={() => (group_by_room = true)}
title="Group sessions by room"
class="flex items-center gap-1.5 px-3 py-1.5 font-medium transition-colors duration-150 {group_by_room
? 'preset-filled-primary'
: 'opacity-60 hover:opacity-90 hover:bg-surface-100-900'}">
<span class="fas fa-layer-group text-xs" aria-hidden="true"></span>
<span class="hidden sm:inline">By Room</span>
</button>
<button
type="button"
onclick={() => (group_by_room = false)}
title="Show all sessions as a flat list"
class="border-surface-200-800 flex items-center gap-1.5 border-l px-3 py-1.5 font-medium transition-colors duration-150 {!group_by_room
? 'preset-filled-primary'
: 'opacity-60 hover:opacity-90 hover:bg-surface-100-900'}">
<span class="fas fa-list text-xs" aria-hidden="true"></span>
<span class="hidden sm:inline">Flat List</span>
</button>
</div>
<button
type="button"
onclick={export_csv}
@@ -225,7 +357,7 @@ function export_json() {
title="Export filtered meetings as CSV"
class="btn btn-sm preset-tonal-primary disabled:opacity-40">
<span class="fas fa-file-csv" aria-hidden="true"></span>
Export CSV
<span class="ml-1 hidden sm:inline">CSV</span>
</button>
<button
type="button"
@@ -234,7 +366,7 @@ function export_json() {
title="Export filtered meetings as JSON"
class="btn btn-sm preset-tonal-surface border-surface-200-800 border disabled:opacity-40">
<span class="fas fa-file-code" aria-hidden="true"></span>
Export JSON
<span class="ml-1 hidden sm:inline">JSON</span>
</button>
</div>
</div>
@@ -268,6 +400,16 @@ function export_json() {
<!-- Filter bar -->
<div
class="bg-surface-100-900 border-surface-200-800 flex flex-row flex-wrap items-end gap-3 rounded-xl border p-3">
<!-- Real meetings only toggle -->
<label class="flex cursor-pointer items-center gap-2 self-end pb-1.5">
<input
type="checkbox"
bind:checked={filter_real_only}
class="checkbox checkbox-sm" />
<span class="text-sm font-medium whitespace-nowrap"
>Real meetings only</span>
</label>
<div class="bg-surface-200-800 hidden h-8 w-px self-end sm:block"></div>
<div>
<label
for="filter_min_p"
@@ -392,196 +534,383 @@ function export_json() {
</div>
{/if}
<!-- Meeting list -->
{#if meetings_filtered.length > 0}
<div class="space-y-2">
{#each meetings_filtered as meeting (meeting.meeting_id)}
<div
class="bg-surface-50-900 border-surface-200-800 overflow-hidden rounded-xl border">
<!-- Accordion header -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- ═══════════════════════════════════════════════════════════
GROUPED BY ROOM VIEW
═══════════════════════════════════════════════════════════ -->
{#if group_by_room}
{#if meetings_grouped.size > 0}
<div class="space-y-2">
{#each meetings_grouped as [room_name, sessions] (room_name)}
{@const stats = room_stats(sessions)}
<div
class="hover:bg-surface-100-900 cursor-pointer p-3 transition-colors duration-200"
onclick={() =>
toggle_accordion(meeting.meeting_id)}>
<div class="flex items-center gap-2">
class="bg-surface-50-900 border-surface-200-800 overflow-hidden rounded-xl border">
<!-- Room header -->
<button
type="button"
onclick={() => toggle_room(room_name)}
class="hover:bg-surface-100-900 flex w-full items-center justify-between gap-2 p-3 text-left transition-colors duration-200">
<div class="min-w-0 flex-1">
<div class="truncate font-semibold">
{meeting.room_name}
{room_name}
</div>
<div class="text-sm opacity-60">
{new Date(
meeting.start_time
).toLocaleString()}
<div
class="mt-0.5 flex flex-wrap gap-3 text-xs opacity-60">
<span>
<span
class="fas fa-calendar-alt mr-1"
aria-hidden="true"></span>
{stats.meeting_count}
{stats.meeting_count === 1
? 'session'
: 'sessions'}
</span>
<span>
<span
class="fas fa-users mr-1"
aria-hidden="true"></span>
{stats.unique_participant_count} unique
{stats.unique_participant_count === 1
? 'participant'
: 'participants'}
</span>
<span class="font-mono">
<span
class="fas fa-clock mr-1"
aria-hidden="true"></span>
{stats.total_duration} total
</span>
</div>
</div>
<span
class="fas flex-none pl-2 text-xs opacity-60"
class:fa-chevron-down={is_room_open(
room_name
)}
class:fa-chevron-up={!is_room_open(
room_name
)}
aria-hidden="true"></span>
</button>
<!-- Room session table -->
{#if is_room_open(room_name)}
<div
class="hidden flex-none items-center gap-4 text-sm opacity-60 sm:flex">
<span title="Duration">
class="border-surface-200-800 overflow-x-auto border-t">
<table class="w-full text-sm">
<thead>
<tr
class="bg-surface-100-900 border-surface-200-800 border-b">
<th
class="px-3 py-2 text-left font-medium whitespace-nowrap opacity-60"
>Date</th>
<th
class="px-3 py-2 text-left font-medium whitespace-nowrap opacity-60"
>Start</th>
<th
class="px-3 py-2 text-left font-medium whitespace-nowrap opacity-60"
>End</th>
<th
class="px-3 py-2 text-left font-medium whitespace-nowrap opacity-60"
>Duration</th>
<th
class="px-3 py-2 text-center font-medium whitespace-nowrap opacity-60"
title="Participant count (after staff exclusion)">#</th>
<th
class="px-3 py-2 text-left font-medium opacity-60"
>Participants</th>
</tr>
</thead>
<tbody>
{#each sessions as m (m.meeting_id)}
{@const mods = m.real_participants.filter((p) => p.role === 'moderator')}
{@const others = m.real_participants.filter((p) => p.role !== 'moderator')}
{@const all_names = m.real_participants.map((p) => `${p.displayName} (${p.role})`).join('\n')}
<tr
class="border-surface-200-800 hover:bg-surface-100-900 border-b transition-colors duration-200">
<td
class="px-3 py-2 whitespace-nowrap">
{new Date(
m.start_time
).toLocaleDateString()}
</td>
<td
class="px-3 py-2 whitespace-nowrap">
{new Date(
m.start_time
).toLocaleTimeString(
[],
{
hour: '2-digit',
minute: '2-digit'
}
)}
</td>
<td
class="px-3 py-2 whitespace-nowrap">
{compute_end_time(m.start_time, m.final_duration)}
</td>
<td
class="px-3 py-2 font-mono whitespace-nowrap">
{m.final_duration}
</td>
<td
class="px-3 py-2 text-center"
title={all_names || 'No participants'}>
{m.real_participant_count}
</td>
<td class="px-3 py-2" title={all_names || 'No participants'}>
{#if m.real_participant_count === 0}
<span class="opacity-40"></span>
{:else}
<div class="space-y-0.5 text-xs">
{#if mods.length > 0}
<div>
<span class="mr-1 opacity-40">Mod:</span>
<span class="font-semibold">{mods.map((p) => p.displayName).join(', ')}</span>
</div>
{/if}
{#if others.length > 0}
<div class="opacity-80">
{others.slice(0, 5).map((p) => p.displayName).join(', ')}{others.length > 5 ? ` +${others.length - 5} more` : ''}
</div>
{/if}
</div>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div
class="bg-surface-100-900 border-surface-200-800 rounded-xl border p-6 text-center">
{#if meetings_all.length > 0}
<div class="font-semibold">
No meetings match the current filters
</div>
<p class="mt-1 text-sm opacity-60">
Try adjusting the filters or clearing the date
range.
</p>
<button
type="button"
onclick={reset_filters}
class="btn btn-sm preset-tonal-surface border-surface-200-800 mt-3 border">
<span class="fas fa-times mr-1" aria-hidden="true"
></span>
Reset Filters
</button>
{:else}
<div class="font-semibold">No Meeting Reports Found</div>
<p class="mt-1 text-sm opacity-60">
There are no Jitsi activity logs to display.
</p>
{/if}
</div>
{/if}
<!-- ═══════════════════════════════════════════════════════════
FLAT LIST VIEW
═══════════════════════════════════════════════════════════ -->
{:else}
{#if meetings_filtered.length > 0}
<div class="space-y-2">
{#each meetings_filtered as meeting (meeting.meeting_id)}
<div
class="bg-surface-50-900 border-surface-200-800 overflow-hidden rounded-xl border">
<!-- Accordion header -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="hover:bg-surface-100-900 cursor-pointer p-3 transition-colors duration-200"
onclick={() =>
toggle_accordion(meeting.meeting_id)}>
<div class="flex items-center gap-2">
<div class="min-w-0 flex-1">
<div class="truncate font-semibold">
{meeting.room_name}
</div>
<div class="text-sm opacity-60">
{new Date(meeting.start_time).toLocaleString()}
{#if meeting.final_duration && meeting.final_duration !== '00:00:00'}
<span class="mx-1 opacity-50"></span>{compute_end_time(meeting.start_time, meeting.final_duration)}
{/if}
</div>
</div>
<div
class="hidden flex-none items-center gap-4 text-sm opacity-60 sm:flex">
<span title="Duration">
<span
class="fas fa-clock mr-1"
aria-hidden="true"></span>
{meeting.final_duration}
</span>
<span title="Participant count">
<span
class="fas fa-users mr-1"
aria-hidden="true"></span>
{meeting.real_participant_count}
</span>
</div>
<div class="flex-none pl-2">
<span
class="fas inline-block transition-transform duration-200"
class:fa-chevron-down={!open_accordions[
meeting.meeting_id
]}
class:fa-chevron-up={open_accordions[
meeting.meeting_id
]}
aria-hidden="true"></span>
</div>
</div>
<!-- Mobile stats row -->
<div
class="mt-1 flex gap-4 text-sm opacity-60 sm:hidden">
<span>
<span
class="fas fa-clock mr-1"
aria-hidden="true"></span>
{meeting.final_duration}
</span>
<span title="Participant count">
<span>
<span
class="fas fa-users mr-1"
aria-hidden="true"></span>
{meeting.final_participant_count}
{meeting.real_participant_count}
{meeting.real_participant_count === 1
? 'participant'
: 'participants'}
</span>
</div>
<div class="flex-none pl-2">
<span
class="fas inline-block transition-transform duration-200"
class:fa-chevron-down={!open_accordions[
meeting.meeting_id
]}
class:fa-chevron-up={open_accordions[
meeting.meeting_id
]}
aria-hidden="true"></span>
</div>
</div>
<!-- Mobile stats row -->
<div
class="mt-1 flex gap-4 text-sm opacity-60 sm:hidden">
<span
><span
class="fas fa-clock mr-1"
aria-hidden="true"></span
>{meeting.final_duration}</span>
<span>
<span
class="fas fa-users mr-1"
aria-hidden="true"></span>
{meeting.final_participant_count}
{meeting.final_participant_count === 1
? 'participant'
: 'participants'}
</span>
</div>
</div>
<!-- Accordion body -->
{#if open_accordions[meeting.meeting_id]}
<div
class="border-surface-200-800 grid grid-cols-1 gap-4 border-t p-4 md:grid-cols-2">
<!-- Event Timeline -->
<div>
<div
class="mb-2 text-xs tracking-wide uppercase opacity-40">
Event Timeline
</div>
{#if meeting.events && meeting.events.length > 0}
<ul class="space-y-1">
{#each meeting.events as event, i (i)}
<li
class="flex items-start gap-2 text-sm">
<span
class="mt-0.5 font-mono text-xs whitespace-nowrap opacity-60">
[{new Date(
event.timestamp
).toLocaleTimeString()}]
</span>
<span>
<!-- Accordion body -->
{#if open_accordions[meeting.meeting_id]}
<div
class="border-surface-200-800 grid grid-cols-1 gap-4 border-t p-4 md:grid-cols-2">
<!-- Event Timeline -->
<div>
<div
class="mb-2 text-xs tracking-wide uppercase opacity-40">
Event Timeline
</div>
{#if meeting.events && meeting.events.length > 0}
<ul class="space-y-1">
{#each meeting.events as event, i (i)}
<li
class="flex items-start gap-2 text-sm">
<span
class="font-semibold">
{ae_util.to_title_case(
event.action.replace(
'jitsi_meeting_',
''
)
)}
class="mt-0.5 font-mono text-xs whitespace-nowrap opacity-60">
[{new Date(
event.timestamp
).toLocaleTimeString()}]
</span>
{#if event.details?.full_name}
<span>
<span
class="opacity-60">
{event.details
.full_name}</span>
{/if}
</span>
</li>
{/each}
</ul>
{:else}
<p class="text-sm italic opacity-60">
No discrete events recorded.
</p>
{/if}
</div>
<!-- Final Participants -->
<div>
<div
class="mb-2 text-xs tracking-wide uppercase opacity-40">
Final Participants ({meeting.final_participant_count})
</div>
{#if meeting.final_participants && meeting.final_participants.length > 0}
<table class="w-full text-sm">
<thead>
<tr
class="border-surface-200-800 border-b">
<th
class="py-1 text-left font-medium opacity-60"
>Name</th>
<th
class="py-1 text-left font-medium opacity-60"
>Role</th>
</tr>
</thead>
<tbody>
{#each meeting.final_participants as participant (participant.displayName)}
<tr
class="border-surface-200-800 hover:bg-surface-100-900 border-b transition-colors duration-200">
<td class="py-1"
>{participant.displayName}</td>
<td class="py-1"
>{ae_util.to_title_case(
participant.role
)}</td>
</tr>
class="font-semibold">
{ae_util.to_title_case(
event.action.replace(
'jitsi_meeting_',
''
)
)}
</span>
{#if event.details?.full_name}
<span
class="opacity-60">
{event
.details
.full_name}
</span>
{/if}
</span>
</li>
{/each}
</tbody>
</table>
{:else}
<p class="text-sm italic opacity-60">
No participant data available.
</p>
{/if}
</ul>
{:else}
<p class="text-sm italic opacity-60">
No discrete events recorded.
</p>
{/if}
</div>
<!-- Final Participants (exclusion-applied) -->
<div>
<div
class="mb-2 text-xs tracking-wide uppercase opacity-40">
Final Participants ({meeting.real_participant_count})
</div>
{#if meeting.real_participants && meeting.real_participants.length > 0}
<table class="w-full text-sm">
<thead>
<tr
class="border-surface-200-800 border-b">
<th
class="py-1 text-left font-medium opacity-60"
>Name</th>
<th
class="py-1 text-left font-medium opacity-60"
>Role</th>
</tr>
</thead>
<tbody>
{#each meeting.real_participants as participant (participant.displayName)}
<tr
class="border-surface-200-800 hover:bg-surface-100-900 border-b transition-colors duration-200">
<td class="py-1"
>{participant.displayName}</td>
<td class="py-1"
>{ae_util.to_title_case(
participant.role
)}</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<p class="text-sm italic opacity-60">
No participant data available.
</p>
{/if}
</div>
</div>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<!-- Empty state -->
<div
class="bg-surface-100-900 border-surface-200-800 rounded-xl border p-6 text-center">
{#if meetings_all.length > 0}
<div class="font-semibold">
No meetings match the current filters
</div>
<p class="mt-1 text-sm opacity-60">
Try lowering the minimum participants or clearing the
date range.
</p>
<button
type="button"
onclick={reset_filters}
class="btn btn-sm preset-tonal-surface border-surface-200-800 mt-3 border">
<span class="fas fa-times mr-1" aria-hidden="true"
></span>
Reset Filters
</button>
{:else}
<div class="font-semibold">No Meeting Reports Found</div>
<p class="mt-1 text-sm opacity-60">
There are no Jitsi activity logs to display.
</p>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div
class="bg-surface-100-900 border-surface-200-800 rounded-xl border p-6 text-center">
{#if meetings_all.length > 0}
<div class="font-semibold">
No meetings match the current filters
</div>
<p class="mt-1 text-sm opacity-60">
Try lowering the minimum participants or clearing
the date range.
</p>
<button
type="button"
onclick={reset_filters}
class="btn btn-sm preset-tonal-surface border-surface-200-800 mt-3 border">
<span class="fas fa-times mr-1" aria-hidden="true"
></span>
Reset Filters
</button>
{:else}
<div class="font-semibold">No Meeting Reports Found</div>
<p class="mt-1 text-sm opacity-60">
There are no Jitsi activity logs to display.
</p>
{/if}
</div>
{/if}
{/if}
{/if}
</div>