Compare commits
3 Commits
409308d2be
...
392217e66c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
392217e66c | ||
|
|
7497bfb9f8 | ||
|
|
3ae9d0a884 |
@@ -183,11 +183,16 @@ fetch(`${api_root_url}/customers/${uuid}`, {
|
|||||||
|
|
||||||
- **`novi_bb_base_url`**: (optional) Base URL used to build links for Bulletin Board notification emails.
|
- **`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.
|
- **`jitsi_exclude_uuids`**: (optional) Array of Novi UUIDs to exclude from Jitsi Reports.
|
||||||
Used to hide known staff and test accounts from the activity report so participant counts and lists
|
This is the canonical staff/test filter. UUIDs are matched case-insensitively against
|
||||||
reflect real member activity only. Names are matched case-insensitively against `final_participants[].displayName`.
|
`final_participants[].novi_uuid` when present. Example: `["uuid-1", "uuid-2"]`.
|
||||||
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.
|
- **`jitsi_known_meetings`**: (optional) Array of meeting names / room names to keep in the report.
|
||||||
|
When this list is non-empty, only matching `room_name` values are shown. Matching is
|
||||||
|
case-insensitive.
|
||||||
|
|
||||||
|
- **Legacy fallback:** `jitsi_exclude_names` is still honored for older configs, but it should be
|
||||||
|
migrated to UUIDs.
|
||||||
|
|
||||||
- **Email config values** (`noreply_email`, `noreply_name`, `admin_email`, `admin_name`): used by functions that send notification emails (BB posts, comments, recovery meetings).
|
- **Email config values** (`noreply_email`, `noreply_name`, `admin_email`, `admin_name`): used by functions that send notification emails (BB posts, comments, recovery meetings).
|
||||||
|
|
||||||
@@ -461,7 +466,7 @@ Moderation permissions are controlled by `novi_jitsi_mod_li` in the IDAA store.
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
**Reminder:** this page is still incomplete. We still need the Novi UUID filter to work and we still need meeting-name whitelist filtering.
|
**Reminder:** this page now filters staff by Novi UUID and can whitelist known meeting names from site config.
|
||||||
|
|
||||||
### View Modes
|
### View Modes
|
||||||
|
|
||||||
@@ -478,32 +483,45 @@ Both modes use the same filtered data set — switching views does not reset fil
|
|||||||
|
|
||||||
| Filter | Default | Logic |
|
| 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** | 2 | Minimum `real_participant_count` to display a session. Used as the only size filter. |
|
||||||
| **Min. Participants** | 0 | Minimum `real_participant_count` to display a session. |
|
| **Room Name** | edit mode only | Case-insensitive substring match against `room_name`. Hidden unless AE global edit mode is on. |
|
||||||
| **Room Name** | (empty) | Case-insensitive substring match against `room_name`. |
|
| **From / To** | last 60 days / today | Date range applied to `start_time`. "To" date includes the full end of day. |
|
||||||
| **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.
|
A "Reset Filters" button appears whenever any filter is non-default.
|
||||||
|
|
||||||
### Staff / Test Exclusion
|
In edit mode, two extra toggles appear:
|
||||||
|
- **Show excluded IDs** — temporarily include the UUIDs listed in `jitsi_exclude_uuids`
|
||||||
|
- **Show all meetings** — temporarily ignore `jitsi_known_meetings`
|
||||||
|
|
||||||
**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.
|
An "Active Exclusions" panel below the filter bar shows the currently applied Novi UUID exclusions
|
||||||
|
and known meeting-name whitelist values. Each list is collapsible so the page stays compact.
|
||||||
|
|
||||||
**Solution:** A configurable exclusion list in `site_cfg_json`:
|
### Staff / Meeting Filtering
|
||||||
|
|
||||||
|
**Problem:** Staff/test accounts and one-off test rooms distort the reports.
|
||||||
|
|
||||||
|
**Site config keys:**
|
||||||
```json
|
```json
|
||||||
{ "jitsi_exclude_names": ["Scott I.", "Michelle V.", "Brie K."] }
|
{
|
||||||
|
"jitsi_exclude_uuids": ["uuid-1", "uuid-2"],
|
||||||
|
"jitsi_known_meetings": ["IDAA-BIPOC-Meeting", "IDAA-Sunday-Meeting"]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**How it works (client-side only, no backend change needed):**
|
**How it works:**
|
||||||
|
|
||||||
1. On load, the page reads `$ae_loc.site_cfg_json?.jitsi_exclude_names` (string array, defaults to `[]`).
|
1. The page reads `$ae_loc.site_cfg_json?.jitsi_exclude_uuids` and excludes matching participants by Novi UUID.
|
||||||
2. For every session, a `real_participants` derived list is computed by filtering `final_participants` against the exclusion list (case-insensitive display name match).
|
The UUID comes from the Jitsi log `url_params.uuid` field. `g_uuid` is the meeting/group UUID and is not used here.
|
||||||
3. `real_participant_count = real_participants.length` — this count drives all filters, stats, and the participant list column in grouped view.
|
2. If a participant record does not include a UUID in the activity log, it is left visible; UUIDs are used whenever available.
|
||||||
4. The raw `final_participant_count` from the API is never shown to the user once an exclusion list is configured.
|
3. `real_participant_count = real_participants.length` drives filters, exports, and the per-meeting attendee count.
|
||||||
|
4. Room-level unique participant counts are computed from Novi UUIDs when present, with display-name fallback only for UUID-less records.
|
||||||
|
5. If `$ae_loc.site_cfg_json?.jitsi_known_meetings` is non-empty, only meetings whose `room_name` matches one of the listed names are shown.
|
||||||
|
6. The Room Name filter is only shown when global edit mode is enabled.
|
||||||
|
|
||||||
**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.
|
**Temporary stopgap:** the report also hides these staff display names through the same UUID-exclusion toggle until the long-term logging fix lands:
|
||||||
|
`Scott I.`, `Brie P.`, `Michelle V.`
|
||||||
|
|
||||||
**Still pending:** the new Jitsi Reports page still needs a real Novi UUID filter and a whitelist of meeting names so staff can narrow the report set without relying only on display names.
|
**Note:** matching is case-insensitive on the stored `room_name` / meeting name.
|
||||||
|
|
||||||
### Summary Stats
|
### Summary Stats
|
||||||
|
|
||||||
@@ -514,7 +532,13 @@ Shown above the meeting list when data is loaded. Stats reflect the **filtered +
|
|||||||
- **Avg Duration** — mean session duration (HH:MM:SS)
|
- **Avg Duration** — mean session duration (HH:MM:SS)
|
||||||
- **Total Duration** — sum of all session durations (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).
|
In grouped view, each room header also shows its own subtotals (meeting count, unique participants by Novi UUID when available).
|
||||||
|
|
||||||
|
### Caching / Load Behavior
|
||||||
|
|
||||||
|
The page now reads cached `activity_log` rows from IndexedDB first, renders that result immediately,
|
||||||
|
then refreshes from the API in the background. That keeps the report usable even when the network
|
||||||
|
round-trip is slow.
|
||||||
|
|
||||||
### Jitsi URL Builder
|
### Jitsi URL Builder
|
||||||
|
|
||||||
@@ -733,4 +757,4 @@ ae_loc.idaa_loc = { novi_uuid: 'test-uuid-value', ... };
|
|||||||
---
|
---
|
||||||
|
|
||||||
**Document Status:** ✅ Current
|
**Document Status:** ✅ Current
|
||||||
**Last Verified:** 2026-05-05 — added Module 5: Jitsi Reports (grouped view, real-meetings filter, staff exclusion via `jitsi_exclude_names`); fixed route tree (`jitsi_reports/` is inside `(idaa)/`)
|
**Last Verified:** 2026-05-06 — added Module 5: Jitsi Reports (grouped view, UUID exclusion, known-meeting whitelist, UUID-based unique counts); fixed route tree (`jitsi_reports/` is inside `(idaa)/`)
|
||||||
|
|||||||
@@ -185,9 +185,8 @@ suddenly jumps to 0 errors, verify it's not because a bad `.d.ts` replaced a pac
|
|||||||
effect without re-login.
|
effect without re-login.
|
||||||
|
|
||||||
### [IDAA] Jitsi Reports still incomplete
|
### [IDAA] Jitsi Reports still incomplete
|
||||||
- [ ] **Finish Jitsi Reports filters** — the new Jitsi Reports page still needs a working Novi UUID
|
- [x] **Finish Jitsi Reports filters** — added Novi UUID exclusion plus meeting-name whitelist
|
||||||
filter and meeting-name whitelist filtering so staff can narrow the report set without relying on
|
filtering, with room-level unique counts based on Novi UUID when present. (2026-05-06)
|
||||||
display-name matching alone.
|
|
||||||
|
|
||||||
### [PWA] Service worker ignoring `chrome-extension://` requests
|
### [PWA] Service worker ignoring `chrome-extension://` requests
|
||||||
Browser console shows repeated errors:
|
Browser console shows repeated errors:
|
||||||
|
|||||||
@@ -51,9 +51,9 @@ let show_llm_api_token = $state(false);
|
|||||||
// Ensure we have a valid object
|
// Ensure we have a valid object
|
||||||
if (!cfg_json) cfg_json = {};
|
if (!cfg_json) cfg_json = {};
|
||||||
|
|
||||||
function add_to_list(key: string) {
|
function add_to_list(key: string, prompt_label: string) {
|
||||||
if (!cfg_json[key]) cfg_json[key] = [];
|
if (!cfg_json[key]) cfg_json[key] = [];
|
||||||
const val = prompt('Enter Novi UUID:');
|
const val = prompt(prompt_label);
|
||||||
if (val) cfg_json[key].push(val);
|
if (val) cfg_json[key].push(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +61,10 @@ function remove_from_list(key: string, index: number) {
|
|||||||
cfg_json[key].splice(index, 1);
|
cfg_json[key].splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function list_count(key: string): number {
|
||||||
|
return Array.isArray(cfg_json[key]) ? cfg_json[key].length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Sync Raw JSON string when entering the tab
|
// Sync Raw JSON string when entering the tab
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (active_tab === 'raw') {
|
if (active_tab === 'raw') {
|
||||||
@@ -365,24 +369,32 @@ $effect(() => {
|
|||||||
|
|
||||||
<!-- UUID Lists -->
|
<!-- UUID Lists -->
|
||||||
<section class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<section class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
{#each [{ key: 'novi_admin_li', label: 'Novi Admins', color: 'text-error-500' }, { key: 'novi_trusted_li', label: 'Novi Trusted', color: 'text-warning-500' }, { key: 'novi_jitsi_mod_li', label: 'Jitsi Moderators', color: 'text-primary-500' }, { key: 'novi_idaa_group_guid_li', label: 'Member Group GUIDs', color: 'text-secondary-500' }] as list (list.key)}
|
{#each [{ key: 'novi_admin_li', label: 'Novi Admins', color: 'text-error-500', prompt: 'Enter Novi UUID:' }, { key: 'novi_trusted_li', label: 'Novi Trusted', color: 'text-warning-500', prompt: 'Enter Novi UUID:' }, { key: 'novi_jitsi_mod_li', label: 'Jitsi Moderators', color: 'text-primary-500', prompt: 'Enter Novi UUID:' }, { key: 'novi_idaa_group_guid_li', label: 'Member Group GUIDs', color: 'text-secondary-500', prompt: 'Enter Group GUID:' }, { key: 'jitsi_exclude_uuids', label: 'Jitsi Excluded UUIDs', color: 'text-error-500', prompt: 'Enter Novi UUID to exclude:' }, { key: 'jitsi_known_meetings', label: 'Known IDAA Meetings', color: 'text-primary-500', prompt: 'Enter meeting name to allow:' }] as list (list.key)}
|
||||||
<div class="bg-surface-500/5 space-y-2 rounded-lg p-3">
|
<details class="bg-surface-500/5 rounded-lg p-3">
|
||||||
<header class="flex items-center justify-between">
|
<summary
|
||||||
<span
|
class="flex cursor-pointer list-none items-center justify-between gap-2 [&::-webkit-details-marker]:hidden">
|
||||||
class="text-[10px] font-black tracking-wider uppercase {list.color}"
|
<span class="min-w-0">
|
||||||
>{list.label}</span>
|
<span
|
||||||
|
class="text-[10px] font-black tracking-wider uppercase {list.color}"
|
||||||
|
>{list.label}</span>
|
||||||
|
<span class="ml-2 text-[10px] opacity-50"
|
||||||
|
>({list_count(list.key)})</span>
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
class="btn btn-icon btn-icon-sm variant-soft-primary"
|
class="btn btn-icon btn-icon-sm variant-soft-primary"
|
||||||
onclick={() => add_to_list(list.key)}>
|
onclick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
add_to_list(list.key, list.prompt);
|
||||||
|
}}>
|
||||||
<Plus size="12" />
|
<Plus size="12" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</summary>
|
||||||
<div class="space-y-1">
|
<div class="mt-3 space-y-1">
|
||||||
{#each cfg_json[list.key] ?? [] as uuid, i (uuid)}
|
{#each cfg_json[list.key] ?? [] as item, i (i)}
|
||||||
<div
|
<div
|
||||||
class="bg-surface-500/10 flex items-center gap-1 rounded p-1 font-mono text-[10px]">
|
class="bg-surface-500/10 flex items-center gap-1 rounded p-1 font-mono text-[10px]">
|
||||||
<span class="grow truncate"
|
<span class="grow truncate">{item}</span>
|
||||||
>{uuid}</span>
|
|
||||||
<button
|
<button
|
||||||
class="text-error-500 transition-transform hover:scale-110"
|
class="text-error-500 transition-transform hover:scale-110"
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
@@ -392,7 +404,7 @@ $effect(() => {
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -270,6 +270,9 @@ export const properties_to_save = [
|
|||||||
'user_id',
|
'user_id',
|
||||||
'user_id_random',
|
'user_id_random',
|
||||||
'external_client_id',
|
'external_client_id',
|
||||||
|
'url_root',
|
||||||
|
'url_full_path',
|
||||||
|
'url_params',
|
||||||
'source',
|
'source',
|
||||||
'object_type',
|
'object_type',
|
||||||
'object_id',
|
'object_id',
|
||||||
|
|||||||
@@ -7,6 +7,28 @@ import {
|
|||||||
import { db_save_ae_obj_li__ae_obj } from '$lib/ae_core/core__idb_dexie';
|
import { db_save_ae_obj_li__ae_obj } from '$lib/ae_core/core__idb_dexie';
|
||||||
import { db_core } from '$lib/ae_core/db_core';
|
import { db_core } from '$lib/ae_core/db_core';
|
||||||
|
|
||||||
|
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[];
|
||||||
|
}
|
||||||
|
|
||||||
// MariaDB TEXT columns come back as JSON strings from the API — parse safely.
|
// MariaDB TEXT columns come back as JSON strings from the API — parse safely.
|
||||||
function safe_parse_meta(raw: unknown): Record<string, unknown> {
|
function safe_parse_meta(raw: unknown): Record<string, unknown> {
|
||||||
if (!raw) return {};
|
if (!raw) return {};
|
||||||
@@ -24,6 +46,49 @@ function parse_duration_seconds(d: string): number {
|
|||||||
return (h || 0) * 3600 + (m || 0) * 60 + (s || 0);
|
return (h || 0) * 3600 + (m || 0) * 60 + (s || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalize_text(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize_uuid(value: unknown): string {
|
||||||
|
return normalize_text(value).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extract_uuid_from_url_params(raw: unknown): string {
|
||||||
|
if (typeof raw !== 'string' || !raw.trim()) return '';
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(raw);
|
||||||
|
return normalize_uuid(params.get('uuid'));
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extract_participant_uuid(source: Record<string, unknown>): string {
|
||||||
|
const nested_user = source.user as Record<string, unknown> | undefined;
|
||||||
|
const nested_context = source.context as Record<string, unknown> | undefined;
|
||||||
|
const nested_context_user = nested_context?.user as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
source.novi_uuid,
|
||||||
|
source.uuid,
|
||||||
|
source.novi_customer_uid,
|
||||||
|
nested_user?.novi_uuid,
|
||||||
|
nested_user?.uuid,
|
||||||
|
nested_context_user?.novi_uuid,
|
||||||
|
nested_context_user?.uuid
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const uuid = normalize_uuid(candidate);
|
||||||
|
if (uuid) return uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Queries all Jitsi-related activity logs and processes them into a structured report,
|
* @description Queries all Jitsi-related activity logs and processes them into a structured report,
|
||||||
* grouped by meeting ID.
|
* grouped by meeting ID.
|
||||||
@@ -36,6 +101,236 @@ function parse_duration_seconds(d: string): number {
|
|||||||
* @param log_lvl The logging level.
|
* @param log_lvl The logging level.
|
||||||
* @returns A structured array of meeting report objects.
|
* @returns A structured array of meeting report objects.
|
||||||
*/
|
*/
|
||||||
|
function build_jitsi_report_from_logs(
|
||||||
|
flat_log_list: any[],
|
||||||
|
log_lvl = 0
|
||||||
|
): MeetingReport[] {
|
||||||
|
// 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, participant> per meeting, keeping the
|
||||||
|
// stable display-name key used by the existing report while preserving Novi UUIDs
|
||||||
|
// when the log payload includes them.
|
||||||
|
//
|
||||||
|
// 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, MeetingReport>();
|
||||||
|
const participant_maps = new Map<string, Map<string, MeetingParticipant>>();
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (!meetings.has(meeting_id)) {
|
||||||
|
meetings.set(meeting_id, {
|
||||||
|
meeting_id,
|
||||||
|
room_name: 'Unknown',
|
||||||
|
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);
|
||||||
|
const log_novi_uuid = extract_uuid_from_url_params(log.url_params);
|
||||||
|
|
||||||
|
if (log.action === 'jitsi_meeting_init') {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge snapshot participant list (has role info — preferred source)
|
||||||
|
const snapshot = meta.participants as
|
||||||
|
| Array<Record<string, unknown>>
|
||||||
|
| undefined;
|
||||||
|
if (Array.isArray(snapshot)) {
|
||||||
|
for (const p of snapshot) {
|
||||||
|
const display_name = normalize_text(p.displayName);
|
||||||
|
if (!display_name) continue;
|
||||||
|
|
||||||
|
const role =
|
||||||
|
typeof p.role === 'string' ? p.role : 'participant';
|
||||||
|
const novi_uuid =
|
||||||
|
extract_participant_uuid(p) ||
|
||||||
|
(p.role === 'moderator'
|
||||||
|
? normalize_uuid(meta.moderator_novi_uuid)
|
||||||
|
: '');
|
||||||
|
const existing_participant = p_map.get(display_name);
|
||||||
|
|
||||||
|
if (!existing_participant) {
|
||||||
|
p_map.set(display_name, {
|
||||||
|
displayName: display_name,
|
||||||
|
role,
|
||||||
|
...(novi_uuid ? { novi_uuid } : {})
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
existing_participant.role !== 'moderator' &&
|
||||||
|
role === 'moderator'
|
||||||
|
) {
|
||||||
|
existing_participant.role = 'moderator';
|
||||||
|
}
|
||||||
|
if (!existing_participant.novi_uuid && novi_uuid) {
|
||||||
|
existing_participant.novi_uuid = novi_uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect participants from join events (may catch people who left before the snapshot)
|
||||||
|
if (log.action === 'jitsi_meeting_participant_joined') {
|
||||||
|
const display_name = normalize_text(meta.full_name);
|
||||||
|
if (display_name) {
|
||||||
|
const role =
|
||||||
|
typeof meta.role === 'string' ? meta.role : 'participant';
|
||||||
|
const novi_uuid = log_novi_uuid || extract_participant_uuid(meta);
|
||||||
|
const existing_participant = p_map.get(display_name);
|
||||||
|
|
||||||
|
if (!existing_participant) {
|
||||||
|
p_map.set(display_name, {
|
||||||
|
displayName: display_name,
|
||||||
|
role,
|
||||||
|
...(novi_uuid ? { novi_uuid } : {})
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
existing_participant.role !== 'moderator' &&
|
||||||
|
role === 'moderator'
|
||||||
|
) {
|
||||||
|
existing_participant.role = 'moderator';
|
||||||
|
}
|
||||||
|
if (!existing_participant.novi_uuid && novi_uuid) {
|
||||||
|
existing_participant.novi_uuid = novi_uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: {
|
||||||
|
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.values()).sort(
|
||||||
|
(a: MeetingParticipant, b: MeetingParticipant) => {
|
||||||
|
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(
|
||||||
|
(a: MeetingEvent, b: MeetingEvent) =>
|
||||||
|
new Date(a.timestamp).getTime() -
|
||||||
|
new Date(b.timestamp).getTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const final_report = Array.from(meetings.values());
|
||||||
|
final_report.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.start_time).getTime() - new Date(a.start_time).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (log_lvl) console.log('Final Jitsi report:', final_report);
|
||||||
|
|
||||||
|
return final_report;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function load_jitsi_report_from_cache({
|
||||||
|
account_id,
|
||||||
|
limit = 500,
|
||||||
|
log_lvl = 0
|
||||||
|
}: {
|
||||||
|
account_id: string;
|
||||||
|
limit?: number;
|
||||||
|
log_lvl?: number;
|
||||||
|
}): Promise<MeetingReport[] | null> {
|
||||||
|
try {
|
||||||
|
const cached_logs = await db_core.activity_log
|
||||||
|
.where('account_id_random')
|
||||||
|
.equals(account_id)
|
||||||
|
.and(
|
||||||
|
(log) =>
|
||||||
|
log.name === 'jitsi_meeting_event' ||
|
||||||
|
log.name === 'jitsi_meeting_stats'
|
||||||
|
)
|
||||||
|
.limit(limit)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
if (cached_logs.length === 0) return null;
|
||||||
|
if (
|
||||||
|
cached_logs.some(
|
||||||
|
(log) => typeof log.url_params !== 'string' || !log.url_params
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (log_lvl) {
|
||||||
|
console.log(
|
||||||
|
'Jitsi report cache is missing url_params; using API refresh for accurate UUID filtering.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (log_lvl) {
|
||||||
|
console.log(
|
||||||
|
`Jitsi report cache hit: ${cached_logs.length} activity_log rows`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return build_jitsi_report_from_logs(cached_logs, log_lvl);
|
||||||
|
} catch (err) {
|
||||||
|
if (log_lvl) console.warn('Jitsi report cache read failed.', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function qry__jitsi_report({
|
export async function qry__jitsi_report({
|
||||||
api_cfg,
|
api_cfg,
|
||||||
account_id,
|
account_id,
|
||||||
@@ -112,140 +407,7 @@ export async function qry__jitsi_report({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Process the flat list into a structured report.
|
return build_jitsi_report_from_logs(flat_log_list, log_lvl);
|
||||||
//
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
if (!meetings.has(meeting_id)) {
|
|
||||||
meetings.set(meeting_id, {
|
|
||||||
meeting_id,
|
|
||||||
room_name: 'Unknown',
|
|
||||||
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') {
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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: {
|
|
||||||
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(
|
|
||||||
(a: any, b: any) =>
|
|
||||||
new Date(a.timestamp).getTime() -
|
|
||||||
new Date(b.timestamp).getTime()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const final_report = Array.from(meetings.values());
|
|
||||||
final_report.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.start_time).getTime() - new Date(a.start_time).getTime()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (log_lvl) console.log('Final Jitsi report:', final_report);
|
|
||||||
|
|
||||||
return final_report;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const load_jitsi_report = qry__jitsi_report;
|
export const load_jitsi_report = qry__jitsi_report;
|
||||||
|
|||||||
@@ -415,6 +415,9 @@ export interface ae_ActivityLog extends ae_BaseObj {
|
|||||||
|
|
||||||
external_client_id?: string;
|
external_client_id?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
|
url_root?: string;
|
||||||
|
url_full_path?: string;
|
||||||
|
url_params?: string;
|
||||||
|
|
||||||
object_type?: string;
|
object_type?: string;
|
||||||
object_id_random?: string;
|
object_id_random?: string;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||||
import { load_jitsi_report } from '$lib/ae_reports/reports_functions';
|
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 { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||||
import { idaa_loc } from '$lib/stores/ae_idaa_stores';
|
import { idaa_loc } from '$lib/stores/ae_idaa_stores';
|
||||||
import JitsiUrlBuilder from './ae_idaa_comp__jitsi_url_builder.svelte';
|
import JitsiUrlBuilder from './ae_idaa_comp__jitsi_url_builder.svelte';
|
||||||
@@ -15,6 +18,7 @@ interface MeetingEvent {
|
|||||||
interface MeetingParticipant {
|
interface MeetingParticipant {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
novi_uuid?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MeetingReport {
|
interface MeetingReport {
|
||||||
@@ -39,6 +43,45 @@ let meetings_loading = $state(true);
|
|||||||
let meetings_error = $state<string | null>(null);
|
let meetings_error = $state<string | null>(null);
|
||||||
let meetings_load_started = $state(false);
|
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(() => {
|
$effect(() => {
|
||||||
if (meetings_load_started) return;
|
if (meetings_load_started) return;
|
||||||
if (!$idaa_loc.novi_verified && !$ae_loc.trusted_access) return;
|
if (!$idaa_loc.novi_verified && !$ae_loc.trusted_access) return;
|
||||||
@@ -52,31 +95,75 @@ $effect(() => {
|
|||||||
meetings_error = null;
|
meetings_error = null;
|
||||||
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
void load_jitsi_report({
|
void (async () => {
|
||||||
api_cfg,
|
const cached = await load_jitsi_report_from_cache({
|
||||||
account_id,
|
account_id,
|
||||||
log_lvl: 1
|
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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 ---
|
// --- Staff/test exclusion ---
|
||||||
// Read display names to exclude from site_cfg_json.jitsi_exclude_names.
|
// UUID-based exclusion is the canonical path; the legacy name list stays as a fallback
|
||||||
// Matched case-insensitively against MeetingParticipant.displayName.
|
// for older site config values until those are cleaned up.
|
||||||
let exclude_names = $derived<string[]>(
|
let exclude_uuids = $derived<string[]>(
|
||||||
(($ae_loc.site_cfg_json as Record<string, unknown>)
|
normalize_list(
|
||||||
?.jitsi_exclude_names as string[] | undefined)
|
($ae_loc.site_cfg_json as Record<string, unknown>)
|
||||||
?.map((n) => n.toLowerCase().trim()) ?? []
|
?.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
|
// Apply exclusion to every meeting — produces the "real" participant list
|
||||||
// used by all downstream filters, stats, and display.
|
// used by all downstream filters, stats, and display.
|
||||||
@@ -84,15 +171,23 @@ let meetings_enriched = $derived<MeetingReportEnriched[]>(
|
|||||||
meetings_all.map((m) => {
|
meetings_all.map((m) => {
|
||||||
// final_participants can be null/undefined if meta_json.participants was null in the API response
|
// final_participants can be null/undefined if meta_json.participants was null in the API response
|
||||||
const all_participants: MeetingParticipant[] = m.final_participants ?? [];
|
const all_participants: MeetingParticipant[] = m.final_participants ?? [];
|
||||||
const real_participants =
|
const real_participants = all_participants.filter((p) => {
|
||||||
exclude_names.length > 0
|
if (show_excluded_uuids) return true;
|
||||||
? all_participants.filter(
|
const participant_uuid = normalize_uuid(p.novi_uuid);
|
||||||
(p) =>
|
if (participant_uuid && exclude_uuid_set.has(participant_uuid)) {
|
||||||
!exclude_names.includes(
|
return false;
|
||||||
p.displayName.toLowerCase().trim()
|
}
|
||||||
)
|
|
||||||
)
|
const participant_name = normalize_meeting_name(p.displayName);
|
||||||
: all_participants;
|
if (
|
||||||
|
exclude_name_set.has(participant_name) ||
|
||||||
|
temp_excluded_name_set.has(participant_name)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...m,
|
...m,
|
||||||
real_participants,
|
real_participants,
|
||||||
@@ -105,26 +200,27 @@ let meetings_enriched = $derived<MeetingReportEnriched[]>(
|
|||||||
let group_by_room = $state(true);
|
let group_by_room = $state(true);
|
||||||
|
|
||||||
// --- Filter state ---
|
// --- Filter state ---
|
||||||
let filter_real_only = $state(false);
|
let filter_min_participants = $state(2);
|
||||||
let filter_min_participants = $state(0);
|
|
||||||
let filter_room_name = $state('');
|
let filter_room_name = $state('');
|
||||||
let filter_date_from = $state('');
|
let filter_date_from = $state(default_filter_date_from);
|
||||||
let filter_date_to = $state('');
|
let filter_date_to = $state(default_filter_date_to);
|
||||||
|
|
||||||
let filters_are_modified = $derived(
|
let filters_are_modified = $derived(
|
||||||
filter_real_only ||
|
filter_min_participants !== 2 ||
|
||||||
filter_min_participants !== 0 ||
|
(filter_room_name !== '' && $ae_loc.edit_mode) ||
|
||||||
filter_room_name !== '' ||
|
show_excluded_uuids ||
|
||||||
filter_date_from !== '' ||
|
show_all_meetings ||
|
||||||
filter_date_to !== ''
|
filter_date_from !== default_filter_date_from ||
|
||||||
|
filter_date_to !== default_filter_date_to
|
||||||
);
|
);
|
||||||
|
|
||||||
function reset_filters() {
|
function reset_filters() {
|
||||||
filter_real_only = false;
|
filter_min_participants = 2;
|
||||||
filter_min_participants = 0;
|
|
||||||
filter_room_name = '';
|
filter_room_name = '';
|
||||||
filter_date_from = '';
|
show_excluded_uuids = false;
|
||||||
filter_date_to = '';
|
show_all_meetings = false;
|
||||||
|
filter_date_from = default_filter_date_from;
|
||||||
|
filter_date_to = default_filter_date_to;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Duration helpers ---
|
// --- Duration helpers ---
|
||||||
@@ -154,16 +250,20 @@ function compute_end_time(start_time: string, duration: string): string {
|
|||||||
// All counts and thresholds use real_participant_count (post-exclusion).
|
// All counts and thresholds use real_participant_count (post-exclusion).
|
||||||
let meetings_filtered = $derived.by(() => {
|
let meetings_filtered = $derived.by(() => {
|
||||||
return meetings_enriched.filter((m) => {
|
return meetings_enriched.filter((m) => {
|
||||||
if (filter_real_only) {
|
if (!show_all_meetings && known_meeting_set.size > 0) {
|
||||||
const dur_secs = parse_duration_seconds(m.final_duration);
|
if (!known_meeting_set.has(normalize_meeting_name(m.room_name))) {
|
||||||
if (m.real_participant_count < 2 && dur_secs <= 300) return false;
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (m.real_participant_count < filter_min_participants) return false;
|
if (m.real_participant_count < filter_min_participants) return false;
|
||||||
if (
|
if ($ae_loc.edit_mode && filter_room_name) {
|
||||||
filter_room_name &&
|
if (
|
||||||
!m.room_name?.toLowerCase().includes(filter_room_name.toLowerCase())
|
!m.room_name
|
||||||
)
|
?.toLowerCase()
|
||||||
return false;
|
.includes(filter_room_name.toLowerCase())
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (filter_date_from) {
|
if (filter_date_from) {
|
||||||
if (Date.parse(m.start_time) < Date.parse(filter_date_from))
|
if (Date.parse(m.start_time) < Date.parse(filter_date_from))
|
||||||
return false;
|
return false;
|
||||||
@@ -230,7 +330,7 @@ function room_stats(sessions: MeetingReportEnriched[]) {
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
const unique_names = new Set(
|
const unique_names = new Set(
|
||||||
sessions.flatMap((m) => m.real_participants.map((p) => p.displayName))
|
sessions.flatMap((m) => m.real_participants.map((p) => participant_identity(p)))
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
meeting_count: sessions.length,
|
meeting_count: sessions.length,
|
||||||
@@ -371,8 +471,8 @@ function export_json() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Jitsi URL Builder — trusted_access only -->
|
<!-- Jitsi URL Builder — edit mode only -->
|
||||||
{#if $ae_loc.trusted_access}
|
{#if $ae_loc.edit_mode && $ae_loc.trusted_access}
|
||||||
<div
|
<div
|
||||||
class="bg-surface-100-900 border-surface-200-800 overflow-hidden rounded-xl border">
|
class="bg-surface-100-900 border-surface-200-800 overflow-hidden rounded-xl border">
|
||||||
<button
|
<button
|
||||||
@@ -400,16 +500,25 @@ function export_json() {
|
|||||||
<!-- Filter bar -->
|
<!-- Filter bar -->
|
||||||
<div
|
<div
|
||||||
class="bg-surface-100-900 border-surface-200-800 flex flex-row flex-wrap items-end gap-3 rounded-xl border p-3">
|
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 -->
|
{#if $ae_loc.edit_mode}
|
||||||
<label class="flex cursor-pointer items-center gap-2 self-end pb-1.5">
|
<label class="flex cursor-pointer items-center gap-2 self-end pb-1.5">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={filter_real_only}
|
bind:checked={show_excluded_uuids}
|
||||||
class="checkbox checkbox-sm" />
|
class="checkbox checkbox-sm" />
|
||||||
<span class="text-sm font-medium whitespace-nowrap"
|
<span class="text-sm font-medium whitespace-nowrap"
|
||||||
>Real meetings only</span>
|
>Show excluded IDs</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="bg-surface-200-800 hidden h-8 w-px self-end sm:block"></div>
|
<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>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="filter_min_p"
|
for="filter_min_p"
|
||||||
@@ -423,19 +532,21 @@ function export_json() {
|
|||||||
bind:value={filter_min_participants}
|
bind:value={filter_min_participants}
|
||||||
class="border-surface-200-800 bg-surface-50-950 w-20 rounded border px-2 py-1" />
|
class="border-surface-200-800 bg-surface-50-950 w-20 rounded border px-2 py-1" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{#if $ae_loc.edit_mode}
|
||||||
<label
|
<div>
|
||||||
for="filter_room"
|
<label
|
||||||
class="mb-1 block text-xs tracking-wide uppercase opacity-40">
|
for="filter_room"
|
||||||
Room Name
|
class="mb-1 block text-xs tracking-wide uppercase opacity-40">
|
||||||
</label>
|
Room Name
|
||||||
<input
|
</label>
|
||||||
type="text"
|
<input
|
||||||
id="filter_room"
|
type="text"
|
||||||
placeholder="Search rooms..."
|
id="filter_room"
|
||||||
bind:value={filter_room_name}
|
placeholder="Search rooms..."
|
||||||
class="border-surface-200-800 bg-surface-50-950 rounded border px-2 py-1" />
|
bind:value={filter_room_name}
|
||||||
</div>
|
class="border-surface-200-800 bg-surface-50-950 rounded border px-2 py-1" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="filter_date_from"
|
for="filter_date_from"
|
||||||
@@ -472,6 +583,40 @@ function export_json() {
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
{#if meetings_loading}
|
||||||
<!-- Loading skeleton -->
|
<!-- Loading skeleton -->
|
||||||
<div
|
<div
|
||||||
@@ -564,7 +709,7 @@ function export_json() {
|
|||||||
? 'session'
|
? 'session'
|
||||||
: 'sessions'}
|
: 'sessions'}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span title="Unique participants (by Novi UUID when available)">
|
||||||
<span
|
<span
|
||||||
class="fas fa-users mr-1"
|
class="fas fa-users mr-1"
|
||||||
aria-hidden="true"></span>
|
aria-hidden="true"></span>
|
||||||
|
|||||||
Reference in New Issue
Block a user