Files
OSIT-AE-App-Svelte/src/routes/idaa/(idaa)/jitsi_reports/+page.svelte
2026-05-06 14:29:27 -04:00

1138 lines
54 KiB
Svelte

<script lang="ts">
import { untrack } from 'svelte';
import { ae_util } from '$lib/ae_utils/ae_utils';
import {
load_jitsi_report,
load_jitsi_report_from_cache
} from '$lib/ae_reports/reports_functions';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { idaa_loc } from '$lib/stores/ae_idaa_stores';
import JitsiUrlBuilder from './ae_idaa_comp__jitsi_url_builder.svelte';
interface MeetingEvent {
timestamp: string;
action: string;
details: { full_name?: string };
}
interface MeetingParticipant {
displayName: string;
role: string;
novi_uuid?: string;
}
interface MeetingReport {
meeting_id: string;
room_name: string;
start_time: string;
final_duration: string;
final_participant_count: number;
final_participants: MeetingParticipant[];
events: MeetingEvent[];
}
// Extends MeetingReport with exclusion-applied participant data
interface MeetingReportEnriched extends MeetingReport {
real_participants: MeetingParticipant[];
real_participant_count: number;
}
// --- Data state ---
let meetings_all = $state<MeetingReport[]>([]);
let meetings_loading = $state(true);
let meetings_error = $state<string | null>(null);
let meetings_load_started = $state(false);
function normalize_text(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalize_uuid(value: unknown): string {
return normalize_text(value).toLowerCase();
}
function normalize_meeting_name(value: unknown): string {
return normalize_text(value).toLowerCase();
}
function normalize_list(value: unknown): string[] {
return Array.isArray(value)
? value
.map((item) => normalize_text(item))
.filter((item) => item.length > 0)
: [];
}
function participant_identity(p: MeetingParticipant): string {
const uuid = normalize_uuid(p.novi_uuid);
if (uuid) return `uuid:${uuid}`;
return `name:${normalize_meeting_name(p.displayName)}`;
}
function format_date_input_value(date: Date): string {
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
return `${year}-${month}-${day}`;
}
const today = new Date();
const default_filter_date_to = format_date_input_value(today);
const default_filter_date_from = format_date_input_value(
new Date(today.getFullYear(), today.getMonth(), today.getDate() - 60)
);
$effect(() => {
if (meetings_load_started) return;
if (!$idaa_loc.novi_verified && !$ae_loc.trusted_access) return;
const api_cfg = $ae_api;
const account_id = $ae_loc.account_id;
if (!api_cfg || !account_id) return;
meetings_load_started = true;
meetings_loading = true;
meetings_error = null;
untrack(() => {
void (async () => {
const cached = await load_jitsi_report_from_cache({
account_id,
log_lvl: 1
});
if (cached && cached.length > 0) {
meetings_all = cached;
meetings_loading = false;
void load_jitsi_report({
api_cfg,
account_id,
log_lvl: 1
})
.then((m: MeetingReport[]) => {
meetings_all = m ?? [];
})
.catch((err: unknown) => {
console.warn('Jitsi report background refresh failed.', err);
});
return;
}
try {
const fresh = await load_jitsi_report({
api_cfg,
account_id,
log_lvl: 1
});
meetings_all = fresh ?? [];
} catch (err: unknown) {
meetings_error = err instanceof Error ? err.message : String(err);
} finally {
meetings_loading = false;
}
})();
});
});
// --- Staff/test exclusion ---
// UUID-based exclusion is the canonical path; the legacy name list stays as a fallback
// for older site config values until those are cleaned up.
let exclude_uuids = $derived<string[]>(
normalize_list(
($ae_loc.site_cfg_json as Record<string, unknown>)
?.jitsi_exclude_uuids
).map((uuid) => uuid.toLowerCase())
);
const temp_excluded_names = ['Scott I.', 'Brie P.', 'Michelle V.'];
let exclude_names = $derived<string[]>(
normalize_list(
($ae_loc.site_cfg_json as Record<string, unknown>)
?.jitsi_exclude_names
).map((name) => name.toLowerCase())
);
let known_meetings = $derived<string[]>(
normalize_list(
($ae_loc.site_cfg_json as Record<string, unknown>)
?.jitsi_known_meetings
).map((name) => name.toLowerCase())
);
let exclude_uuid_set = $derived(new Set(exclude_uuids));
let exclude_name_set = $derived(new Set(exclude_names));
let temp_excluded_name_set = $derived(
new Set(temp_excluded_names.map((name) => name.toLowerCase()))
);
let known_meeting_set = $derived(new Set(known_meetings));
let show_excluded_uuids = $state(false);
let show_all_meetings = $state(false);
// 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 = all_participants.filter((p) => {
if (show_excluded_uuids) return true;
const participant_uuid = normalize_uuid(p.novi_uuid);
if (participant_uuid && exclude_uuid_set.has(participant_uuid)) {
return false;
}
const participant_name = normalize_meeting_name(p.displayName);
if (
exclude_name_set.has(participant_name) ||
temp_excluded_name_set.has(participant_name)
) {
return false;
}
return true;
});
return {
...m,
real_participants,
real_participant_count: real_participants.length
};
})
);
// --- View mode ---
let group_by_room = $state(true);
// --- Filter state ---
let filter_min_participants = $state(2);
let filter_room_name = $state('');
let filter_date_from = $state(default_filter_date_from);
let filter_date_to = $state(default_filter_date_to);
let filters_are_modified = $derived(
filter_min_participants !== 2 ||
(filter_room_name !== '' && $ae_loc.edit_mode) ||
show_excluded_uuids ||
show_all_meetings ||
filter_date_from !== default_filter_date_from ||
filter_date_to !== default_filter_date_to
);
function reset_filters() {
filter_min_participants = 2;
filter_room_name = '';
show_excluded_uuids = false;
show_all_meetings = false;
filter_date_from = default_filter_date_from;
filter_date_to = default_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_enriched.filter((m) => {
if (!show_all_meetings && known_meeting_set.size > 0) {
if (!known_meeting_set.has(normalize_meeting_name(m.room_name))) {
return false;
}
}
if (m.real_participant_count < filter_min_participants) return false;
if ($ae_loc.edit_mode && filter_room_name) {
if (
!m.room_name
?.toLowerCase()
.includes(filter_room_name.toLowerCase())
)
return false;
}
if (filter_date_from) {
if (Date.parse(m.start_time) < Date.parse(filter_date_from))
return false;
}
if (filter_date_to) {
if (
Date.parse(m.start_time) >
Date.parse(filter_date_to + 'T23:59:59.999')
)
return false;
}
return true;
});
});
// --- Derived: grouped by room ---
// Rooms sorted by most-recent session desc; sessions within each room sorted desc.
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(b.start_time).getTime() -
new Date(a.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 ---
let summary = $derived.by(() => {
const count = meetings_filtered.length;
const total_participants = meetings_filtered.reduce(
(sum, m) => sum + m.real_participant_count,
0
);
const total_secs = meetings_filtered.reduce(
(sum, m) => sum + parse_duration_seconds(m.final_duration),
0
);
const avg_secs = count > 0 ? Math.round(total_secs / count) : 0;
return {
count,
total_participants,
avg_duration: format_seconds(avg_secs),
total_duration: format_seconds(total_secs)
};
});
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) => participant_identity(p)))
);
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);
let copied_participants_meeting_id = $state<string | null>(null);
let copied_participants_timeout: ReturnType<typeof setTimeout> | null = null;
function build_participant_copy_text(participants: MeetingParticipant[]): string {
return participants
.map((participant) =>
participant.role === 'moderator'
? `Mod: ${participant.displayName}`
: participant.displayName
)
.join('\n');
}
async function copy_participants(
meeting_id: string,
participants: MeetingParticipant[]
) {
const text = build_participant_copy_text(participants);
if (!text) return;
try {
await navigator.clipboard.writeText(text);
copied_participants_meeting_id = meeting_id;
if (copied_participants_timeout) {
clearTimeout(copied_participants_timeout);
}
copied_participants_timeout = setTimeout(() => {
if (copied_participants_meeting_id === meeting_id) {
copied_participants_meeting_id = null;
}
}, 2000);
} catch (err) {
console.warn('Failed to copy participants to clipboard.', err);
}
}
// --- Export ---
function download_file(content: string, filename: string, mime: string) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function export_csv() {
const rows: string[][] = [
[
'Meeting ID',
'Room Name',
'Start Time',
'Duration',
'Participant Count',
'Participants'
]
];
for (const m of meetings_filtered) {
rows.push([
m.meeting_id ?? '',
m.room_name ?? '',
m.start_time ? new Date(m.start_time).toISOString() : '',
m.final_duration ?? '',
String(m.real_participant_count),
m.real_participants.map((p) => p.displayName).join('; ')
]);
}
const csv = rows
.map((r) =>
r.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',')
)
.join('\n');
download_file(csv, 'jitsi_meeting_report.csv', 'text/csv;charset=utf-8;');
}
function export_json() {
download_file(
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'
);
}
</script>
<svelte:head>
<title>&AElig;: Jitsi Meeting Reports</title>
</svelte:head>
<div class="text-surface-950 dark:text-surface-50 w-full max-w-5xl space-y-4 p-4">
<!-- Page header: view toggle + export buttons -->
<div class="flex flex-row flex-wrap items-center justify-between gap-2">
<h1 class="!text-surface-950 dark:!text-surface-50 text-xl font-bold">
Jitsi Meeting Reports
</h1>
<div class="flex items-center gap-2">
<!-- View mode toggle -->
<div class="border-surface-200-800 flex overflow-hidden rounded-lg border bg-surface-50-950 p-0.5 text-sm shadow-sm">
<button
type="button"
onclick={() => (group_by_room = true)}
title="Group sessions by room"
aria-pressed={group_by_room}
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-all duration-150 {group_by_room
? '!bg-primary-400 !text-white !shadow-sm ring-1 ring-primary-700'
: '!bg-transparent !text-surface-700 hover:!bg-surface-100-900 hover:!text-surface-950 dark:!text-surface-200 dark:hover:!text-surface-50'}">
<span class="fas fa-layer-group text-xs" aria-hidden="true"></span>
<span>By Room</span>
</button>
<button
type="button"
onclick={() => (group_by_room = false)}
title="Show all sessions as a flat list"
aria-pressed={!group_by_room}
class="border-surface-200-800 flex items-center gap-1.5 rounded-md border-l px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-all duration-150 {!group_by_room
? '!bg-primary-400 !text-white !shadow-sm ring-1 ring-primary-700'
: '!bg-transparent !text-surface-700 hover:!bg-surface-100-900 hover:!text-surface-950 dark:!text-surface-200 dark:hover:!text-surface-50'}">
<span class="fas fa-list text-xs" aria-hidden="true"></span>
<span>Flat List</span>
</button>
</div>
<button
type="button"
onclick={export_csv}
disabled={meetings_filtered.length === 0}
title="Export filtered meetings as CSV"
class="btn btn-sm preset-tonal-surface border-surface-200-800 border disabled:opacity-40">
<span class="fas fa-file-csv" aria-hidden="true"></span>
<span class="ml-1 hidden sm:inline">CSV</span>
</button>
{#if $ae_loc.edit_mode}
<button
type="button"
onclick={export_json}
disabled={meetings_filtered.length === 0}
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>
<span class="ml-1 hidden sm:inline">JSON</span>
</button>
{/if}
</div>
</div>
<!-- Jitsi URL Builder — edit mode only -->
{#if $ae_loc.edit_mode && $ae_loc.trusted_access}
<div
class="bg-surface-100-900 border-surface-200-800 overflow-hidden rounded-xl border">
<button
type="button"
onclick={() => (show_url_builder = !show_url_builder)}
class="hover:bg-surface-200-800 flex w-full items-center justify-between gap-2 p-3 text-left transition-colors duration-200">
<span class="flex items-center gap-2 text-sm font-semibold">
<span class="fas fa-tools" aria-hidden="true"></span>
Jitsi URL Builder
</span>
<span
class="fas text-xs opacity-60"
class:fa-chevron-down={!show_url_builder}
class:fa-chevron-up={show_url_builder}
aria-hidden="true"></span>
</button>
{#if show_url_builder}
<div class="border-surface-200-800 border-t p-4">
<JitsiUrlBuilder />
</div>
{/if}
</div>
{/if}
<!-- 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">
{#if $ae_loc.edit_mode}
<label class="flex cursor-pointer items-center gap-2 self-end pb-1.5">
<input
type="checkbox"
bind:checked={show_excluded_uuids}
class="checkbox checkbox-sm" />
<span class="text-sm font-medium whitespace-nowrap"
>Show excluded IDs</span>
</label>
<label class="flex cursor-pointer items-center gap-2 self-end pb-1.5">
<input
type="checkbox"
bind:checked={show_all_meetings}
class="checkbox checkbox-sm" />
<span class="text-sm font-medium whitespace-nowrap"
>Show all meetings</span>
</label>
<div class="bg-surface-200-800 hidden h-8 w-px self-end sm:block"></div>
{/if}
<div>
<label
for="filter_min_p"
class="mb-1 block text-xs tracking-wide uppercase opacity-40">
Min. Participants
</label>
<input
type="number"
id="filter_min_p"
min="0"
bind:value={filter_min_participants}
class="border-surface-200-800 bg-surface-50-950 w-20 rounded border px-2 py-1" />
</div>
{#if $ae_loc.edit_mode}
<div>
<label
for="filter_room"
class="mb-1 block text-xs tracking-wide uppercase opacity-40">
Room Name
</label>
<input
type="text"
id="filter_room"
placeholder="Search rooms..."
bind:value={filter_room_name}
class="border-surface-200-800 bg-surface-50-950 rounded border px-2 py-1" />
</div>
{/if}
<div>
<label
for="filter_date_from"
class="mb-1 block text-xs tracking-wide uppercase opacity-40">
From
</label>
<input
type="date"
id="filter_date_from"
bind:value={filter_date_from}
class="border-surface-200-800 bg-surface-50-950 rounded border px-2 py-1" />
</div>
<div>
<label
for="filter_date_to"
class="mb-1 block text-xs tracking-wide uppercase opacity-40">
To
</label>
<input
type="date"
id="filter_date_to"
bind:value={filter_date_to}
class="border-surface-200-800 bg-surface-50-950 rounded border px-2 py-1" />
</div>
{#if filters_are_modified}
<button
type="button"
onclick={reset_filters}
class="btn btn-sm preset-tonal-surface border-surface-200-800 self-end border"
title="Reset all filters to defaults">
<span class="fas fa-times" aria-hidden="true"></span>
Reset
</button>
{/if}
</div>
{#if $ae_loc.edit_mode && (exclude_uuids.length > 0 || known_meetings.length > 0)}
<div class="bg-surface-100-900 border-surface-200-800 rounded-xl border p-3">
<div class="mb-2 text-xs tracking-wide uppercase opacity-50">
Active Exclusions
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
<details class="bg-surface-50-950 rounded-lg border border-dashed border-opacity-30 p-2">
<summary class="cursor-pointer list-none font-semibold">
Excluded Novi UUIDs ({exclude_uuids.length})
</summary>
<div class="mt-2 space-y-1 text-xs font-mono">
{#each exclude_uuids as uuid (uuid)}
<div class="bg-surface-200-800 rounded px-2 py-1">
{uuid}
</div>
{/each}
</div>
</details>
<details class="bg-surface-50-950 rounded-lg border border-dashed border-opacity-30 p-2">
<summary class="cursor-pointer list-none font-semibold">
Known Meeting Names ({known_meetings.length})
</summary>
<div class="mt-2 space-y-1 text-xs font-mono">
{#each known_meetings as meeting_name (meeting_name)}
<div class="bg-surface-200-800 rounded px-2 py-1">
{meeting_name}
</div>
{/each}
</div>
</details>
</div>
</div>
{/if}
{#if meetings_loading}
<!-- Loading skeleton -->
<div
class="animate-pulse space-y-2"
role="status"
aria-live="polite"
aria-label="Loading meeting reports">
{#each [1, 2, 3, 4] as _, i (i)}
<div class="bg-surface-200-800 h-14 w-full rounded-xl"></div>
{/each}
</div>
{:else if meetings_error}
<!-- Error state -->
<div class="bg-error-100 border-error-300 rounded-xl border p-4">
<div class="font-bold">Error Loading Reports</div>
<p class="mt-1">
An error occurred while fetching the meeting reports:
</p>
<pre
class="mt-2 overflow-auto text-xs whitespace-pre-wrap">{meetings_error}</pre>
</div>
{:else}
<!-- Summary stats -->
{#if meetings_all.length > 0}
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div
class="bg-surface-100-900 border-surface-200-800 rounded-xl border p-3 text-center">
<div class="text-2xl font-bold">{summary.count}</div>
<div class="text-xs tracking-wide uppercase opacity-40">
Meetings Shown
</div>
</div>
<div
class="bg-surface-100-900 border-surface-200-800 rounded-xl border p-3 text-center">
<div class="text-2xl font-bold">
{summary.total_participants}
</div>
<div class="text-xs tracking-wide uppercase opacity-40">
Total Participants
</div>
</div>
<div
class="bg-surface-100-900 border-surface-200-800 rounded-xl border p-3 text-center">
<div class="font-mono text-2xl font-bold">
{summary.avg_duration}
</div>
<div class="text-xs tracking-wide uppercase opacity-40">
Avg Duration
</div>
</div>
<div
class="bg-surface-100-900 border-surface-200-800 rounded-xl border p-3 text-center">
<div class="font-mono text-2xl font-bold">
{summary.total_duration}
</div>
<div class="text-xs tracking-wide uppercase opacity-40">
Total Duration
</div>
</div>
</div>
{/if}
<!-- ═══════════════════════════════════════════════════════════
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="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">
{room_name}
</div>
<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 title="Unique participants (by Novi UUID when available)">
<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="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')}
{@const participant_copy_text = build_participant_copy_text(m.real_participants)}
<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-1 text-xs">
{#if mods.length > 0}
<div class="whitespace-normal break-words">
<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="whitespace-normal break-words opacity-80">
{others.map((p) => p.displayName).join(', ')}
</div>
{/if}
<button
type="button"
onclick={() =>
copy_participants(
m.meeting_id,
m.real_participants
)}
class="inline-flex items-center gap-1 rounded border border-surface-200-800 bg-surface-100-900 px-2 py-1 text-xs font-medium transition-colors hover:bg-surface-200-800"
title="Copy participants to clipboard">
<span
class="fas fa-copy"
aria-hidden="true"></span>
{copied_participants_meeting_id === m.meeting_id
? 'Copied'
: 'Copy names'}
</button>
</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>
<span
class="fas fa-users mr-1"
aria-hidden="true"></span>
{meeting.real_participant_count}
{meeting.real_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>
<span
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}
</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 flex items-center justify-between gap-2">
<div
class="text-xs tracking-wide uppercase opacity-40">
Final Participants ({meeting.real_participant_count})
</div>
<button
type="button"
onclick={() =>
copy_participants(
meeting.meeting_id,
meeting.real_participants
)}
class="inline-flex items-center gap-1 rounded border border-surface-200-800 bg-surface-100-900 px-2 py-1 text-xs font-medium transition-colors hover:bg-surface-200-800"
title="Copy participants to clipboard">
<span
class="fas fa-copy"
aria-hidden="true"></span>
{copied_participants_meeting_id === meeting.meeting_id
? 'Copied'
: 'Copy names'}
</button>
</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>
{/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>