917 lines
43 KiB
Svelte
917 lines
43 KiB
Svelte
<script lang="ts">
|
|
import { untrack } from 'svelte';
|
|
import { ae_util } from '$lib/ae_utils/ae_utils';
|
|
import { load_jitsi_report } 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;
|
|
}
|
|
|
|
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);
|
|
|
|
$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 load_jitsi_report({
|
|
api_cfg,
|
|
account_id,
|
|
log_lvl: 1
|
|
})
|
|
.then((m: MeetingReport[]) => {
|
|
meetings_all = m ?? [];
|
|
meetings_loading = false;
|
|
})
|
|
.catch((err: unknown) => {
|
|
meetings_error =
|
|
err instanceof Error ? err.message : String(err);
|
|
meetings_loading = false;
|
|
});
|
|
});
|
|
});
|
|
|
|
// --- 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 ---
|
|
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_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_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())
|
|
)
|
|
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 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 ---
|
|
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) => 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 });
|
|
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>Æ: Jitsi Meeting Reports</title>
|
|
</svelte:head>
|
|
|
|
<div class="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-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 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}
|
|
disabled={meetings_filtered.length === 0}
|
|
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>
|
|
<span class="ml-1 hidden sm:inline">CSV</span>
|
|
</button>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Jitsi URL Builder — trusted_access only -->
|
|
{#if $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">
|
|
<!-- 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"
|
|
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>
|
|
<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>
|
|
<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 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>
|
|
<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')}
|
|
<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>
|
|
<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 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>
|
|
{/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>
|