Skip to content
This repository was archived by the owner on Nov 14, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src-tauri/src/commands/meetings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,11 @@ pub async fn meetings_list_scheduled_bots(
let mut url = format!("{base}/v1/bot/list");
if let Some(ids) = calendar_event_ids.as_ref() {
if !ids.is_empty() {
let joined = ids.join(",");
let joined = ids
.iter()
.map(|id| encode_query_value(id))
.collect::<Vec<_>>()
.join(",");
url.push_str(&format!("?calendarEventIds={joined}"));
}
}
Expand Down Expand Up @@ -1524,6 +1528,10 @@ fn is_url_safe_id(s: &str) -> bool {
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
}

fn encode_query_value(s: &str) -> String {
url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
Expand Down Expand Up @@ -1893,6 +1901,12 @@ mod tests {
assert!(!is_url_safe_id("bot#frag"));
}

#[test]
fn query_value_encoding_preserves_safe_event_ids_and_escapes_reserved_chars() {
assert_eq!(encode_query_value("event_123-abc.def"), "event_123-abc.def");
assert_eq!(encode_query_value("event 1?x=2#frag"), "event+1%3Fx%3D2%23frag");
}

#[test]
fn meeting_event_parses_recurring_fields() {
let json = r#"{
Expand Down
50 changes: 36 additions & 14 deletions src/components/MeetingsWindow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
saveMeetingsCache,
} from '../lib/meetingsCache';
import { isAlreadyScheduledError } from '../lib/invite-errors';
import { botForEvent } from '../desktop-alt/lib/meetings-model';
import {
botForEvent,
calendarEventIdsForBotLookup,
mergeScheduledBotLookups,
} from '../desktop-alt/lib/meetings-model';
import {
UNATTRIBUTED,
companyOptions,
Expand Down Expand Up @@ -511,28 +515,24 @@
loading = true;
listError = null;
try {
// Fetch events + bots + memberships + connected accounts in parallel.
// Fetch events + memberships + connected accounts in parallel, then
// fetch bots using the actual event ids. hq-pro's full bot list is a
// legacy/recent-bots convenience path and can miss a row; the per-event
// path is the authoritative "does this visible event already have a
// bot?" source.
//
// Catch the two primary list calls individually instead of letting
// Catch the primary list calls individually instead of letting
// Promise.all reject on first failure: an outage that 500s
// /v1/calendar/events should not blank in-memory bot state, and
// vice versa, so the 30s poll can recover one independently. Memberships
// and accounts already swallow to [] for the same reason — a person
// with zero connected Google accounts still wants the events render
// (the empty-state below handles that branch).
// vice versa, so the 30s poll can recover one independently.
// Memberships and accounts already swallow to [] for the same reason.
let upcomingErr: unknown = null;
let botsErr: unknown = null;
const [evts, bots, membershipRows, accts] = await Promise.all([
const [evts, membershipRows, accts] = await Promise.all([
invoke<MeetingEvent[]>('meetings_list_upcoming').catch((err: unknown) => {
upcomingErr = err;
return null;
}),
invoke<ScheduledBot[]>('meetings_list_scheduled_bots', {
calendarEventIds: null,
}).catch((err: unknown) => {
botsErr = err;
return null;
}),
// Memberships are tiny + rarely change — fetched on every open is
// cheap and avoids stale company-name display after the user joins
// a new company elsewhere.
Expand All @@ -541,6 +541,28 @@
() => [] as GoogleAccount[],
),
]);
const botEventIds = calendarEventIdsForBotLookup(evts ?? events);
let eventBotsErr: unknown = null;
let fullBotsErr: unknown = null;
const [eventBots, fullBots] = await Promise.all([
botEventIds.length === 0
? Promise.resolve([] as ScheduledBot[])
: invoke<ScheduledBot[]>('meetings_list_scheduled_bots', {
calendarEventIds: botEventIds,
}).catch((err: unknown) => {
eventBotsErr = err;
return null;
}),
invoke<ScheduledBot[]>('meetings_list_scheduled_bots', {
calendarEventIds: null,
}).catch((err: unknown) => {
fullBotsErr = err;
return null;
}),
]);
const bots = mergeScheduledBotLookups(botEventIds, eventBots, fullBots);
if (botEventIds.length > 0 && eventBots === null) botsErr = eventBotsErr;
else if (botEventIds.length === 0 && fullBots === null) botsErr = fullBotsErr;
// Only overwrite on success so a transient 500 keeps the last good
// snapshot in memory until recovery. The error banner still wins in
// the render branch order, but the cached state is correct for the
Expand Down
69 changes: 69 additions & 0 deletions src/desktop-alt/lib/meetings-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import {
botForEvent,
buildConnectedCalendarRows,
buildRefreshProblemReport,
calendarEventIdsForBotLookup,
dayLabel,
groupByDay,
isAuthError,
meetingsRefreshNotice,
mergeScheduledBotLookups,
mergeScheduledBots,
pickLiveMeeting,
recurringSeriesId,
isRecurringMeeting,
Expand Down Expand Up @@ -357,6 +360,72 @@ describe('meetings-model', () => {
});
});

describe('bot list lookup helpers', () => {
it('dedupes event ids before asking the backend for per-event bot state', () => {
expect(
calendarEventIdsForBotLookup([
eventAt('event-1', new Date(2026, 4, 27, 12, 0, 0)),
eventAt('event-2', new Date(2026, 4, 27, 13, 0, 0)),
eventAt('event-1', new Date(2026, 4, 27, 14, 0, 0)),
{ ...eventAt(' ', new Date(2026, 4, 27, 15, 0, 0)), id: ' ' },
]),
).toEqual(['event-1', 'event-2']);
});

it('keeps authoritative per-event bot rows before legacy full-list rows', () => {
const eventBot: ScheduledBot = {
botId: 'bot-1',
meetingUrl: 'https://meet.google.com/abc-defg-hij',
platform: 'google_meet',
status: 'scheduled',
calendarEventId: 'event-1',
autoScheduled: true,
meetingTitle: 'Authoritative row',
};
const fullListBot: ScheduledBot = {
...eventBot,
meetingTitle: 'Legacy full-list row',
};
const recordedBot: ScheduledBot = {
botId: 'bot-2',
meetingUrl: 'https://meet.google.com/def-ghij-klm',
platform: 'google_meet',
status: 'completed',
calendarEventId: 'event-2',
autoScheduled: true,
meetingTitle: 'Recorded row',
};

expect(mergeScheduledBots([eventBot], [fullListBot, recordedBot])).toEqual([
eventBot,
recordedBot,
]);
});

it('requires per-event bot rows when visible event ids are known', () => {
const eventBot: ScheduledBot = {
botId: 'bot-1',
meetingUrl: 'https://meet.google.com/abc-defg-hij',
platform: 'google_meet',
status: 'scheduled',
calendarEventId: 'event-1',
autoScheduled: true,
};
const fullListBot: ScheduledBot = {
...eventBot,
botId: 'bot-legacy',
};

expect(mergeScheduledBotLookups(['event-1'], null, [fullListBot])).toBeNull();
expect(mergeScheduledBotLookups(['event-1'], [eventBot], null)).toEqual([
eventBot,
]);
expect(mergeScheduledBotLookups([], null, [fullListBot])).toEqual([
fullListBot,
]);
});
});

// US-010 — "Done — transcript saved" must be gated on the REAL source-landed
// signal, not on bot.status === 'completed' alone. The two can diverge: the
// bot lifecycle status flips on the Recall webhook / retry path while the
Expand Down
33 changes: 33 additions & 0 deletions src/desktop-alt/lib/meetings-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,39 @@ export function botForEvent(
});
}

export function calendarEventIdsForBotLookup(events: MeetingEvent[]): string[] {
return Array.from(
new Set(
events
.map((event) => event.id?.trim())
.filter((id): id is string => typeof id === 'string' && id.length > 0),
),
);
}

export function mergeScheduledBots(
primary: ScheduledBot[],
secondary: ScheduledBot[],
): ScheduledBot[] {
const byId = new Map<string, ScheduledBot>();
for (const bot of [...primary, ...secondary]) {
if (!byId.has(bot.botId)) byId.set(bot.botId, bot);
}
return Array.from(byId.values());
}

export function mergeScheduledBotLookups(
eventIds: string[],
eventBots: ScheduledBot[] | null,
fullBots: ScheduledBot[] | null,
): ScheduledBot[] | null {
if (eventIds.length > 0) {
if (eventBots === null) return null;
return mergeScheduledBots(eventBots, fullBots ?? []);
}
return fullBots;
}

export function isToday(event: MeetingEvent, now = new Date()): boolean {
const start = eventStart(event);
if (!start) return false;
Expand Down
48 changes: 36 additions & 12 deletions src/desktop-alt/lib/meetings-store.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import { loadMeetingsCache, saveMeetingsCache } from '../../lib/meetingsCache';
import {
buildRefreshProblemReport,
botForEvent,
calendarEventIdsForBotLookup,
eventMeetingUrl,
friendlyError,
isAuthError,
meetingsRefreshNotice,
mergeScheduledBotLookups,
recurringSeriesId,
} from './meetings-model';
import type {
Expand Down Expand Up @@ -141,8 +143,9 @@ function hydrateFromCache() {
* Live fetch — mirrors MeetingsWindow.svelte's mount `refresh()`. The alt
* window must fetch calendar data itself: it can't piggyback on the classic
* window's cache because localStorage is per-WebviewWindow. Fetches
* events + bots + memberships + connected accounts in parallel, fans out to
* per-account calendars, populates the model, and persists the snapshot.
* events + memberships + connected accounts, then fetches authoritative
* per-event bot state for the visible agenda before fanning out to calendars,
* populating the model, and persisting the snapshot.
*
* Errors are NOT swallowed to a blank state: on failure we keep whatever the
* cache already painted, log the error, and surface a message (auth failures
Expand All @@ -153,17 +156,8 @@ async function refresh() {
loading = true;
membershipsError = '';
try {
const [evts, bots, members, accts] = await Promise.all([
const [evts, members, accts] = await Promise.all([
invoke<MeetingEvent[]>('meetings_list_upcoming'),
// Best-effort: a bots-list hiccup must not blank the recording pills or
// trip the "couldn't refresh" notice. The meetings list (above) is the
// only call that defines a healthy refresh; everything else degrades.
invoke<ScheduledBot[]>('meetings_list_scheduled_bots', {
calendarEventIds: null,
}).catch((err) => {
console.error('meetings_list_scheduled_bots failed:', err);
return null as ScheduledBot[] | null;
}),
invoke<CompanyMembership[]>('meetings_list_memberships').catch((err) => {
console.error('meetings_list_memberships failed:', err);
membershipsError = 'Could not load calendar routing.';
Expand All @@ -180,6 +174,36 @@ async function refresh() {
fetchError = '';
refreshBlocked = false;
lastRefreshErrorRaw = '';
const botEventIds = calendarEventIdsForBotLookup(evts ?? []);
let eventBotsErr: unknown = null;
const [eventBots, fullBots] = await Promise.all([
botEventIds.length === 0
? Promise.resolve([] as ScheduledBot[])
: invoke<ScheduledBot[]>('meetings_list_scheduled_bots', {
calendarEventIds: botEventIds,
}).catch((err) => {
eventBotsErr = err;
console.error('meetings_list_scheduled_bots per-event failed:', err);
return null as ScheduledBot[] | null;
}),
// Best-effort legacy full list for recorded/recent bot surfaces. Row
// invite state is driven by the per-event list above.
invoke<ScheduledBot[]>('meetings_list_scheduled_bots', {
calendarEventIds: null,
}).catch((err) => {
console.error('meetings_list_scheduled_bots full-list failed:', err);
return null as ScheduledBot[] | null;
}),
]);
const bots = mergeScheduledBotLookups(botEventIds, eventBots, fullBots);
if (botEventIds.length > 0 && eventBots === null) {
fetchError = friendlyError(
eventBotsErr,
'Could not refresh meeting bot status.',
);
refreshBlocked = !isAuthError(eventBotsErr);
lastRefreshErrorRaw = String(eventBotsErr ?? '');
}
// Keep the previously-painted pills on a transient bots miss.
if (bots !== null) {
botsByEventId = buildBotMap(bots);
Expand Down
Loading