diff --git a/apps/desktop/src/components/devtool/seed/shared/calendar.ts b/apps/desktop/src/components/devtool/seed/shared/calendar.ts
index 733c2d09c6..70017cc460 100644
--- a/apps/desktop/src/components/devtool/seed/shared/calendar.ts
+++ b/apps/desktop/src/components/devtool/seed/shared/calendar.ts
@@ -18,10 +18,12 @@ export const createCalendar = () => {
"Shared Calendar",
]);
+ const calendarId = id();
return {
- id: id(),
+ id: calendarId,
data: {
user_id: DEFAULT_USER_ID,
+ tracking_id_calendar: `mock-${calendarId}`,
name: template,
created_at: faker.date.past({ years: 1 }).toISOString(),
enabled: faker.datatype.boolean(),
diff --git a/apps/desktop/src/components/devtool/seed/shared/event.ts b/apps/desktop/src/components/devtool/seed/shared/event.ts
index 78463abdb6..13ba7c0f5b 100644
--- a/apps/desktop/src/components/devtool/seed/shared/event.ts
+++ b/apps/desktop/src/components/devtool/seed/shared/event.ts
@@ -177,10 +177,12 @@ export const createEvent = (calendar_id: string) => {
description = faker.helpers.arrayElement(topics);
}
+ const eventId = id();
return {
- id: id(),
+ id: eventId,
data: {
user_id: DEFAULT_USER_ID,
+ tracking_id_event: `mock-${eventId}`,
calendar_id,
title,
started_at: startsAt.toISOString(),
diff --git a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants.tsx b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants.tsx
deleted file mode 100644
index 4d7428a7a0..0000000000
--- a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants.tsx
+++ /dev/null
@@ -1,432 +0,0 @@
-import { X } from "lucide-react";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-
-import type { SpeakerHintStorage, Transcript } from "@hypr/store";
-import { Badge } from "@hypr/ui/components/ui/badge";
-import { Button } from "@hypr/ui/components/ui/button";
-import { cn } from "@hypr/utils";
-
-import * as main from "../../../../../../store/tinybase/main";
-import { useTabs } from "../../../../../../store/zustand/tabs/index";
-
-function createHuman(store: any, userId: string, name: string) {
- const humanId = crypto.randomUUID();
- store.setRow("humans", humanId, {
- user_id: userId,
- created_at: new Date().toISOString(),
- name,
- email: "",
- org_id: "",
- job_title: "",
- linkedin_username: "",
- is_user: false,
- memo: "",
- });
- return humanId;
-}
-
-function linkHumanToSession(
- store: any,
- userId: string,
- sessionId: string,
- humanId: string,
-) {
- const mappingId = crypto.randomUUID();
- store.setRow("mapping_session_participant", mappingId, {
- user_id: userId,
- created_at: new Date().toISOString(),
- session_id: sessionId,
- human_id: humanId,
- });
-}
-
-function createAndLinkHuman(
- store: any,
- userId: string,
- sessionId: string,
- name: string,
-) {
- const humanId = createHuman(store, userId, name);
- linkHumanToSession(store, userId, sessionId, humanId);
- return humanId;
-}
-
-export function ParticipantsDisplay({ sessionId }: { sessionId: string }) {
- const mappingIds = main.UI.useSliceRowIds(
- main.INDEXES.sessionParticipantsBySession,
- sessionId,
- main.STORE_ID,
- ) as string[];
-
- return (
-
- );
-}
-
-function useParticipantDetails(mappingId: string) {
- const result = main.UI.useResultRow(
- main.QUERIES.sessionParticipantsWithDetails,
- mappingId,
- main.STORE_ID,
- );
-
- if (!result) {
- return null;
- }
-
- return {
- mappingId,
- humanId: result.human_id as string,
- humanName: (result.human_name as string) || "",
- humanEmail: (result.human_email as string | undefined) || undefined,
- humanJobTitle: (result.human_job_title as string | undefined) || undefined,
- humanLinkedinUsername:
- (result.human_linkedin_username as string | undefined) || undefined,
- humanIsUser: result.human_is_user as boolean,
- orgId: (result.org_id as string | undefined) || undefined,
- orgName: result.org_name as string | undefined,
- sessionId: result.session_id as string,
- };
-}
-
-function parseHumanIdFromHintValue(value: unknown): string | undefined {
- const data =
- typeof value === "string"
- ? (() => {
- try {
- return JSON.parse(value);
- } catch {
- return undefined;
- }
- })()
- : value;
-
- if (data && typeof data === "object" && "human_id" in data) {
- const humanId = (data as Record).human_id;
- return typeof humanId === "string" ? humanId : undefined;
- }
-
- return undefined;
-}
-
-function useRemoveParticipant({
- mappingId,
- assignedHumanId,
- sessionId,
-}: {
- mappingId: string;
- assignedHumanId: string | undefined;
- sessionId: string | undefined;
-}) {
- const store = main.UI.useStore(main.STORE_ID);
-
- return useCallback(() => {
- if (!store) {
- return;
- }
-
- if (assignedHumanId && sessionId) {
- const hintIdsToDelete: string[] = [];
-
- store.forEachRow("speaker_hints", (hintId, _forEachCell) => {
- const hint = store.getRow("speaker_hints", hintId) as
- | SpeakerHintStorage
- | undefined;
- if (!hint || hint.type !== "user_speaker_assignment") {
- return;
- }
-
- const transcriptId = hint.transcript_id;
- if (typeof transcriptId !== "string") {
- return;
- }
-
- const transcript = store.getRow("transcripts", transcriptId) as
- | Transcript
- | undefined;
- if (!transcript || transcript.session_id !== sessionId) {
- return;
- }
-
- const hintHumanId = parseHumanIdFromHintValue(hint.value);
- if (hintHumanId === assignedHumanId) {
- hintIdsToDelete.push(hintId);
- }
- });
-
- hintIdsToDelete.forEach((hintId) => {
- store.delRow("speaker_hints", hintId);
- });
- }
-
- store.delRow("mapping_session_participant", mappingId);
- }, [store, mappingId, assignedHumanId, sessionId]);
-}
-
-function ParticipantChip({ mappingId }: { mappingId: string }) {
- const details = useParticipantDetails(mappingId);
- const openNew = useTabs.getState().openNew;
-
- const assignedHumanId = details?.humanId;
- const sessionId = details?.sessionId;
-
- const handleRemove = useRemoveParticipant({
- mappingId,
- assignedHumanId,
- sessionId,
- });
-
- const handleClick = useCallback(() => {
- if (assignedHumanId) {
- openNew({
- type: "contacts",
- state: { selectedOrganization: null, selectedPerson: assignedHumanId },
- });
- }
- }, [openNew, assignedHumanId]);
-
- if (!details) {
- return null;
- }
-
- const { humanName } = details;
-
- return (
-
- {humanName || "Unknown"}
-
-
- );
-}
-
-function ParticipantChipInput({
- sessionId,
- mappingIds,
-}: {
- sessionId: string;
- mappingIds: string[];
-}) {
- const [inputValue, setInputValue] = useState("");
- const [showDropdown, setShowDropdown] = useState(false);
- const [selectedIndex, setSelectedIndex] = useState(0);
- const inputRef = useRef(null);
- const containerRef = useRef(null);
- const store = main.UI.useStore(main.STORE_ID);
- const userId = main.UI.useValue("user_id", main.STORE_ID);
- const allHumanIds = main.UI.useRowIds("humans", main.STORE_ID) as string[];
- const queries = main.UI.useQueries(main.STORE_ID);
-
- const existingHumanIds = useMemo(() => {
- if (!queries) {
- return new Set();
- }
-
- const ids = new Set();
- for (const mappingId of mappingIds) {
- const result = queries.getResultRow(
- main.QUERIES.sessionParticipantsWithDetails,
- mappingId,
- );
- if (result?.human_id) {
- ids.add(result.human_id as string);
- }
- }
- return ids;
- }, [mappingIds, queries]);
-
- const candidates = useMemo(() => {
- const searchLower = inputValue.toLowerCase();
- return allHumanIds
- .filter((humanId: string) => !existingHumanIds.has(humanId))
- .map((humanId: string) => {
- const human = store?.getRow("humans", humanId);
- if (!human) {
- return null;
- }
-
- const name = (human.name || "") as string;
- const email = (human.email || "") as string;
- const nameMatch = name.toLowerCase().includes(searchLower);
- const emailMatch = email.toLowerCase().includes(searchLower);
-
- if (inputValue && !nameMatch && !emailMatch) {
- return null;
- }
-
- return {
- id: humanId,
- name,
- email,
- orgId: human.org_id as string | undefined,
- jobTitle: human.job_title as string | undefined,
- isNew: false,
- };
- })
- .filter((h): h is NonNullable => h !== null);
- }, [inputValue, allHumanIds, existingHumanIds, store]);
-
- const showCustomOption =
- inputValue.trim() &&
- !candidates.some((c) => c.name.toLowerCase() === inputValue.toLowerCase());
-
- const dropdownOptions = showCustomOption
- ? [
- {
- id: "new",
- name: inputValue.trim(),
- isNew: true,
- email: "",
- orgId: undefined,
- jobTitle: undefined,
- },
- ...candidates,
- ]
- : candidates;
-
- const handleAddParticipant = useCallback(
- (option: {
- id: string;
- name: string;
- isNew?: boolean;
- email?: string;
- orgId?: string;
- jobTitle?: string;
- }) => {
- if (!store || !userId) {
- return;
- }
-
- if (option.isNew) {
- createAndLinkHuman(store, userId, sessionId, option.name);
- } else {
- linkHumanToSession(store, userId, sessionId, option.id);
- }
-
- setInputValue("");
- setShowDropdown(false);
- setSelectedIndex(0);
- inputRef.current?.focus();
- },
- [store, userId, sessionId],
- );
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Enter" && inputValue.trim()) {
- e.preventDefault();
- if (dropdownOptions.length > 0) {
- handleAddParticipant(dropdownOptions[selectedIndex]);
- }
- } else if (e.key === "ArrowDown") {
- e.preventDefault();
- setSelectedIndex((prev) =>
- prev < dropdownOptions.length - 1 ? prev + 1 : prev,
- );
- } else if (e.key === "ArrowUp") {
- e.preventDefault();
- setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
- } else if (e.key === "Escape") {
- setShowDropdown(false);
- setSelectedIndex(0);
- } else if (e.key === "Backspace" && !inputValue && mappingIds.length > 0) {
- const lastMappingId = mappingIds[mappingIds.length - 1];
- if (store) {
- store.delRow("mapping_session_participant", lastMappingId);
- }
- }
- };
-
- const handleInputChange = (value: string) => {
- setInputValue(value);
- setShowDropdown(true);
- setSelectedIndex(0);
- };
-
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (
- containerRef.current &&
- !containerRef.current.contains(event.target as Node)
- ) {
- setShowDropdown(false);
- }
- };
-
- document.addEventListener("mousedown", handleClickOutside);
- return () => document.removeEventListener("mousedown", handleClickOutside);
- }, []);
-
- return (
-
-
inputRef.current?.focus()}
- >
- {mappingIds.map((mappingId) => (
-
- ))}
-
handleInputChange(e.target.value)}
- onKeyDown={handleKeyDown}
- onFocus={() => setShowDropdown(true)}
- />
-
- {showDropdown && inputValue.trim() && dropdownOptions.length > 0 && (
-
-
- {dropdownOptions.map((option, index) => (
-
- ))}
-
-
- )}
-
- );
-}
diff --git a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/chip.tsx b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/chip.tsx
new file mode 100644
index 0000000000..e86a1f1be4
--- /dev/null
+++ b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/chip.tsx
@@ -0,0 +1,175 @@
+import { X } from "lucide-react";
+import { useCallback } from "react";
+
+import type { SpeakerHintStorage } from "@hypr/store";
+import { Badge } from "@hypr/ui/components/ui/badge";
+import { Button } from "@hypr/ui/components/ui/button";
+
+import * as main from "../../../../../../../store/tinybase/main";
+import { useTabs } from "../../../../../../../store/zustand/tabs/index";
+
+export function ParticipantChip({ mappingId }: { mappingId: string }) {
+ const details = useParticipantDetails(mappingId);
+
+ const assignedHumanId = details?.humanId;
+ const sessionId = details?.sessionId;
+ const source = details?.source;
+
+ const handleRemove = useRemoveParticipant({
+ mappingId,
+ assignedHumanId,
+ sessionId,
+ source,
+ });
+
+ const handleClick = useCallback(() => {
+ if (assignedHumanId) {
+ useTabs.getState().openNew({
+ type: "contacts",
+ state: { selectedOrganization: null, selectedPerson: assignedHumanId },
+ });
+ }
+ }, [assignedHumanId]);
+
+ if (!details || source === "excluded") {
+ return null;
+ }
+
+ const { humanName } = details;
+
+ return (
+
+ {humanName || "Unknown"}
+
+
+ );
+}
+
+function useParticipantDetails(mappingId: string) {
+ const result = main.UI.useResultRow(
+ main.QUERIES.sessionParticipantsWithDetails,
+ mappingId,
+ main.STORE_ID,
+ );
+ const source = main.UI.useCell(
+ "mapping_session_participant",
+ mappingId,
+ "source",
+ main.STORE_ID,
+ );
+
+ if (!result) {
+ return null;
+ }
+
+ return {
+ mappingId,
+ humanId: result.human_id as string,
+ humanName: (result.human_name as string) || "",
+ humanEmail: (result.human_email as string | undefined) || undefined,
+ humanJobTitle: (result.human_job_title as string | undefined) || undefined,
+ humanLinkedinUsername:
+ (result.human_linkedin_username as string | undefined) || undefined,
+ humanIsUser: result.human_is_user as boolean,
+ orgId: (result.org_id as string | undefined) || undefined,
+ orgName: result.org_name as string | undefined,
+ sessionId: result.session_id as string,
+ source: source as string | undefined,
+ };
+}
+
+function parseHumanIdFromHintValue(value: unknown): string | undefined {
+ let data = value;
+ if (typeof value === "string") {
+ try {
+ data = JSON.parse(value);
+ } catch {
+ return undefined;
+ }
+ }
+
+ if (data && typeof data === "object" && "human_id" in data) {
+ const humanId = (data as Record).human_id;
+ return typeof humanId === "string" ? humanId : undefined;
+ }
+
+ return undefined;
+}
+
+function useRemoveParticipant({
+ mappingId,
+ assignedHumanId,
+ sessionId,
+ source,
+}: {
+ mappingId: string;
+ assignedHumanId: string | undefined;
+ sessionId: string | undefined;
+ source: string | undefined;
+}) {
+ const store = main.UI.useStore(main.STORE_ID);
+ const indexes = main.UI.useIndexes(main.STORE_ID);
+
+ return useCallback(() => {
+ if (!store) {
+ return;
+ }
+
+ if (assignedHumanId && sessionId && indexes) {
+ const hintIdsToDelete: string[] = [];
+
+ const transcriptIds = indexes.getSliceRowIds(
+ main.INDEXES.transcriptBySession,
+ sessionId,
+ );
+
+ for (const transcriptId of transcriptIds) {
+ const hintIds = indexes.getSliceRowIds(
+ main.INDEXES.speakerHintsByTranscript,
+ transcriptId,
+ );
+
+ for (const hintId of hintIds) {
+ const hint = store.getRow("speaker_hints", hintId) as
+ | SpeakerHintStorage
+ | undefined;
+ if (!hint || hint.type !== "user_speaker_assignment") {
+ continue;
+ }
+
+ const hintHumanId = parseHumanIdFromHintValue(hint.value);
+ if (hintHumanId === assignedHumanId) {
+ hintIdsToDelete.push(hintId);
+ }
+ }
+ }
+
+ for (const hintId of hintIdsToDelete) {
+ store.delRow("speaker_hints", hintId);
+ }
+ }
+
+ if (source === "auto") {
+ store.setPartialRow("mapping_session_participant", mappingId, {
+ source: "excluded",
+ });
+ } else {
+ store.delRow("mapping_session_participant", mappingId);
+ }
+ }, [store, indexes, mappingId, assignedHumanId, sessionId, source]);
+}
diff --git a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/dropdown.tsx b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/dropdown.tsx
new file mode 100644
index 0000000000..1c750a423d
--- /dev/null
+++ b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/dropdown.tsx
@@ -0,0 +1,61 @@
+import { cn } from "@hypr/utils";
+
+type DropdownOption = {
+ id: string;
+ name: string;
+ isNew?: boolean;
+ email?: string;
+ orgId?: string;
+ jobTitle?: string;
+};
+
+export function ParticipantDropdown({
+ options,
+ selectedIndex,
+ onSelect,
+ onHover,
+}: {
+ options: DropdownOption[];
+ selectedIndex: number;
+ onSelect: (option: DropdownOption) => void;
+ onHover: (index: number) => void;
+}) {
+ if (options.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {options.map((option, index) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/index.tsx b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/index.tsx
new file mode 100644
index 0000000000..9c82602832
--- /dev/null
+++ b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/index.tsx
@@ -0,0 +1,10 @@
+import { ParticipantInput } from "./input";
+
+export function ParticipantsDisplay({ sessionId }: { sessionId: string }) {
+ return (
+
+ );
+}
diff --git a/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/input.tsx b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/input.tsx
new file mode 100644
index 0000000000..6eb1a64df4
--- /dev/null
+++ b/apps/desktop/src/components/main/body/sessions/outer-header/metadata/participants/input.tsx
@@ -0,0 +1,339 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import { useAutoCloser } from "../../../../../../../hooks/useAutoCloser";
+import * as main from "../../../../../../../store/tinybase/main";
+import { ParticipantChip } from "./chip";
+import { ParticipantDropdown } from "./dropdown";
+
+export function ParticipantInput({ sessionId }: { sessionId: string }) {
+ const {
+ inputValue,
+ showDropdown,
+ setShowDropdown,
+ selectedIndex,
+ setSelectedIndex,
+ mappingIds,
+ dropdownOptions,
+ handleAddParticipant,
+ handleInputChange,
+ deleteLastParticipant,
+ resetInput,
+ } = useParticipantInput(sessionId);
+
+ const inputRef = useRef(null);
+ const containerRef = useAutoCloser(() => setShowDropdown(false), {
+ esc: false,
+ outside: true,
+ });
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && inputValue.trim()) {
+ e.preventDefault();
+ if (dropdownOptions.length > 0) {
+ handleAddParticipant(dropdownOptions[selectedIndex]);
+ inputRef.current?.focus();
+ }
+ } else if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setSelectedIndex((prev) =>
+ prev < dropdownOptions.length - 1 ? prev + 1 : prev,
+ );
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
+ } else if (e.key === "Escape") {
+ resetInput();
+ } else if (e.key === "Backspace" && !inputValue) {
+ deleteLastParticipant();
+ }
+ };
+
+ const handleSelect = (option: Candidate) => {
+ handleAddParticipant(option);
+ inputRef.current?.focus();
+ };
+
+ return (
+
+
inputRef.current?.focus()}
+ >
+ {mappingIds.map((mappingId) => (
+
+ ))}
+
+
handleInputChange(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onFocus={() => setShowDropdown(true)}
+ />
+
+
+ {showDropdown && inputValue.trim() && (
+
+ )}
+
+ );
+}
+
+type Candidate = {
+ id: string;
+ name: string;
+ email?: string;
+ orgId?: string;
+ jobTitle?: string;
+ isNew?: boolean;
+};
+
+function useSessionParticipants(sessionId: string) {
+ const queries = main.UI.useQueries(main.STORE_ID);
+
+ const mappingIds = main.UI.useSliceRowIds(
+ main.INDEXES.sessionParticipantsBySession,
+ sessionId,
+ main.STORE_ID,
+ ) as string[];
+
+ const existingHumanIds = useMemo(() => {
+ if (!queries) {
+ return new Set();
+ }
+
+ const ids = new Set();
+ for (const mappingId of mappingIds) {
+ const result = queries.getResultRow(
+ main.QUERIES.sessionParticipantsWithDetails,
+ mappingId,
+ );
+ if (result?.human_id) {
+ ids.add(result.human_id as string);
+ }
+ }
+ return ids;
+ }, [mappingIds, queries]);
+
+ return { mappingIds, existingHumanIds };
+}
+
+function useCandidateSearch(
+ inputValue: string,
+ existingHumanIds: Set,
+): Candidate[] {
+ const store = main.UI.useStore(main.STORE_ID);
+ const allHumanIds = main.UI.useRowIds("humans", main.STORE_ID) as string[];
+
+ return useMemo(() => {
+ const searchLower = inputValue.toLowerCase();
+ return allHumanIds
+ .filter((humanId: string) => !existingHumanIds.has(humanId))
+ .map((humanId: string) => {
+ const human = store?.getRow("humans", humanId);
+ if (!human) {
+ return null;
+ }
+
+ const name = (human.name || "") as string;
+ const email = (human.email || "") as string;
+ const nameMatch = name.toLowerCase().includes(searchLower);
+ const emailMatch = email.toLowerCase().includes(searchLower);
+
+ if (inputValue && !nameMatch && !emailMatch) {
+ return null;
+ }
+
+ return {
+ id: humanId,
+ name,
+ email,
+ orgId: human.org_id as string | undefined,
+ jobTitle: human.job_title as string | undefined,
+ isNew: false,
+ };
+ })
+ .filter((h): h is NonNullable => h !== null);
+ }, [inputValue, allHumanIds, existingHumanIds, store]);
+}
+
+function useDropdownOptions(
+ inputValue: string,
+ candidates: Candidate[],
+): Candidate[] {
+ return useMemo(() => {
+ const showCustomOption =
+ inputValue.trim() &&
+ !candidates.some(
+ (c) => c.name.toLowerCase() === inputValue.toLowerCase(),
+ );
+
+ if (!showCustomOption) {
+ return candidates;
+ }
+
+ return [
+ {
+ id: "new",
+ name: inputValue.trim(),
+ isNew: true,
+ email: "",
+ orgId: undefined,
+ jobTitle: undefined,
+ },
+ ...candidates,
+ ];
+ }, [inputValue, candidates]);
+}
+
+function useParticipantMutations(sessionId: string, mappingIds: string[]) {
+ const store = main.UI.useStore(main.STORE_ID);
+ const userId = main.UI.useValue("user_id", main.STORE_ID);
+
+ const createHuman = useCreateHuman(userId || "");
+ const linkHumanToSession = useLinkHumanToSession(userId || "", sessionId);
+
+ const addParticipant = useCallback(
+ (option: Candidate) => {
+ if (!userId) {
+ return;
+ }
+
+ if (option.isNew) {
+ const humanId = createHuman(option.name);
+ linkHumanToSession(humanId);
+ } else {
+ linkHumanToSession(option.id);
+ }
+ },
+ [userId, createHuman, linkHumanToSession],
+ );
+
+ const deleteLastParticipant = useCallback(() => {
+ if (mappingIds.length > 0 && store) {
+ const lastMappingId = mappingIds[mappingIds.length - 1];
+ store.delRow("mapping_session_participant", lastMappingId);
+ }
+ }, [mappingIds, store]);
+
+ return { addParticipant, deleteLastParticipant };
+}
+
+function useParticipantInput(sessionId: string) {
+ const [inputValue, setInputValue] = useState("");
+ const [showDropdown, setShowDropdown] = useState(false);
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ const { mappingIds, existingHumanIds } = useSessionParticipants(sessionId);
+ const candidates = useCandidateSearch(inputValue, existingHumanIds);
+ const dropdownOptions = useDropdownOptions(inputValue, candidates);
+ const { addParticipant, deleteLastParticipant } = useParticipantMutations(
+ sessionId,
+ mappingIds,
+ );
+
+ useEffect(() => {
+ if (selectedIndex >= dropdownOptions.length && dropdownOptions.length > 0) {
+ setSelectedIndex(dropdownOptions.length - 1);
+ } else if (dropdownOptions.length === 0) {
+ setSelectedIndex(0);
+ }
+ }, [dropdownOptions.length, selectedIndex]);
+
+ const resetInput = useCallback(() => {
+ setInputValue("");
+ setShowDropdown(false);
+ setSelectedIndex(0);
+ }, []);
+
+ const handleAddParticipant = useCallback(
+ (option: Candidate) => {
+ addParticipant(option);
+ resetInput();
+ },
+ [addParticipant, resetInput],
+ );
+
+ const handleInputChange = useCallback((value: string) => {
+ setInputValue(value);
+ setShowDropdown(true);
+ setSelectedIndex(0);
+ }, []);
+
+ return {
+ inputValue,
+ showDropdown,
+ setShowDropdown,
+ selectedIndex,
+ setSelectedIndex,
+ mappingIds,
+ dropdownOptions,
+ handleAddParticipant,
+ handleInputChange,
+ deleteLastParticipant,
+ resetInput,
+ };
+}
+
+function useLinkHumanToSession(
+ userId: string,
+ sessionId: string,
+): (humanId: string) => void {
+ const linkMapping = main.UI.useSetRowCallback(
+ "mapping_session_participant",
+ () => crypto.randomUUID(),
+ (p: { humanId: string }) => ({
+ user_id: userId,
+ created_at: new Date().toISOString(),
+ session_id: sessionId,
+ human_id: p.humanId,
+ source: "manual",
+ }),
+ [userId, sessionId],
+ main.STORE_ID,
+ );
+
+ return useCallback(
+ (humanId: string) => {
+ linkMapping({ humanId });
+ },
+ [linkMapping],
+ );
+}
+
+function useCreateHuman(userId: string): (name: string) => string {
+ const createHuman = main.UI.useSetRowCallback(
+ "humans",
+ (p: { name: string; humanId: string }) => p.humanId,
+ (p: { name: string; humanId: string }) => ({
+ user_id: userId,
+ created_at: new Date().toISOString(),
+ name: p.name,
+ email: "",
+ org_id: "",
+ job_title: "",
+ linkedin_username: "",
+ is_user: false,
+ memo: "",
+ }),
+ [userId],
+ main.STORE_ID,
+ );
+
+ return useCallback(
+ (name: string) => {
+ const humanId = crypto.randomUUID();
+ createHuman({ name, humanId });
+ return humanId;
+ },
+ [createHuman],
+ );
+}
diff --git a/apps/desktop/src/components/settings/calendar/configure/apple.tsx b/apps/desktop/src/components/settings/calendar/configure/apple.tsx
index 83b348cf33..5f3fe28082 100644
--- a/apps/desktop/src/components/settings/calendar/configure/apple.tsx
+++ b/apps/desktop/src/components/settings/calendar/configure/apple.tsx
@@ -4,7 +4,6 @@ import { AlertCircleIcon, ArrowRightIcon, CheckIcon } from "lucide-react";
import { useMemo } from "react";
import {
- type AppleCalendar,
commands as appleCalendarCommands,
colorToCSS,
} from "@hypr/plugin-apple-calendar";
@@ -21,6 +20,7 @@ import { Button } from "@hypr/ui/components/ui/button";
import { cn } from "@hypr/utils";
import * as main from "../../../../store/tinybase/main";
+import { findCalendarByTrackingId } from "../../../../utils/calendar";
import { PROVIDERS } from "../shared";
import {
type CalendarGroup,
@@ -188,25 +188,6 @@ function useAppleCalendarSelection() {
const calendars = main.UI.useTable("calendars", main.STORE_ID);
const { user_id } = main.UI.useValues(main.STORE_ID);
- const setCalendarRow = main.UI.useSetRowCallback(
- "calendars",
- (cal: AppleCalendar) => cal.id,
- (cal: AppleCalendar, store) => {
- const existing = store.getRow("calendars", cal.id);
- return {
- user_id: user_id!,
- created_at: existing?.created_at || new Date().toISOString(),
- name: cal.title,
- enabled: existing?.enabled ?? false,
- provider: "apple",
- source: cal.source.title,
- color: colorToCSS(cal.color),
- };
- },
- [user_id],
- main.STORE_ID,
- );
-
const { mutate: syncCalendars, isPending } = useMutation({
mutationKey: ["appleCalendars", "sync"],
mutationFn: async () => {
@@ -219,10 +200,27 @@ function useAppleCalendarSelection() {
}
return result.data;
},
- onSuccess: (calendars) => {
- store?.transaction(() => {
- for (const cal of calendars) {
- setCalendarRow(cal);
+ onSuccess: (incomingCalendars) => {
+ if (!store || !user_id) return;
+
+ store.transaction(() => {
+ for (const cal of incomingCalendars) {
+ const existingRowId = findCalendarByTrackingId(store, cal.id);
+ const rowId = existingRowId ?? crypto.randomUUID();
+ const existing = existingRowId
+ ? store.getRow("calendars", existingRowId)
+ : null;
+
+ store.setRow("calendars", rowId, {
+ user_id,
+ created_at: existing?.created_at || new Date().toISOString(),
+ tracking_id_calendar: cal.id,
+ name: cal.title,
+ enabled: existing?.enabled ?? false,
+ provider: "apple",
+ source: cal.source.title,
+ color: colorToCSS(cal.color),
+ });
}
});
},
diff --git a/apps/desktop/src/hooks/useAutoCloser.ts b/apps/desktop/src/hooks/useAutoCloser.ts
index 0a9e3c73a4..2771e59729 100644
--- a/apps/desktop/src/hooks/useAutoCloser.ts
+++ b/apps/desktop/src/hooks/useAutoCloser.ts
@@ -1,5 +1,6 @@
-import { useCallback, useEffect, useRef } from "react";
+import { useCallback, useRef } from "react";
import { useHotkeys } from "react-hotkeys-hook";
+import { useOnClickOutside } from "usehooks-ts";
export function useAutoCloser(
onClose: () => void,
@@ -18,23 +19,10 @@ export function useAutoCloser(
}, [onClose]);
useHotkeys("esc", handleClose, { enabled: esc }, [handleClose]);
-
- useEffect(() => {
- if (!outside) {
- return;
- }
-
- const handleClickOutside = (event: MouseEvent) => {
- if (ref.current && !ref.current.contains(event.target as Node)) {
- handleClose();
- }
- };
-
- document.addEventListener("mousedown", handleClickOutside);
- return () => {
- document.removeEventListener("mousedown", handleClickOutside);
- };
- }, [handleClose, outside]);
+ useOnClickOutside(
+ ref as React.RefObject,
+ outside ? handleClose : () => {},
+ );
return ref;
}
diff --git a/apps/desktop/src/services/apple-calendar/ctx.ts b/apps/desktop/src/services/apple-calendar/ctx.ts
index bd2382d25c..f6bfc1c464 100644
--- a/apps/desktop/src/services/apple-calendar/ctx.ts
+++ b/apps/desktop/src/services/apple-calendar/ctx.ts
@@ -8,14 +8,24 @@ export interface Ctx {
from: Date;
to: Date;
calendarIds: Set;
+ calendarTrackingIdToId: Map;
}
export function createCtx(store: Store, queries: Queries): Ctx | null {
const resultTable = queries.getResultTable(QUERIES.enabledAppleCalendars);
const calendarIds = new Set(Object.keys(resultTable));
+ const calendarTrackingIdToId = new Map();
+
+ for (const calendarId of calendarIds) {
+ const calendar = store.getRow("calendars", calendarId);
+ const trackingId = calendar?.tracking_id_calendar as string | undefined;
+ if (trackingId) {
+ calendarTrackingIdToId.set(trackingId, calendarId);
+ }
+ }
- if (calendarIds.size === 0) {
+ if (calendarTrackingIdToId.size === 0) {
return null;
}
@@ -32,6 +42,7 @@ export function createCtx(store: Store, queries: Queries): Ctx | null {
from,
to,
calendarIds,
+ calendarTrackingIdToId,
};
}
diff --git a/apps/desktop/src/services/apple-calendar/fetch/existing.ts b/apps/desktop/src/services/apple-calendar/fetch/existing.ts
index 3c81fbc752..ef8e0be886 100644
--- a/apps/desktop/src/services/apple-calendar/fetch/existing.ts
+++ b/apps/desktop/src/services/apple-calendar/fetch/existing.ts
@@ -1,8 +1,8 @@
import type { Ctx } from "../ctx";
import type { ExistingEvent } from "./types";
-export function fetchExistingEvents(ctx: Ctx): Array {
- const events: Array = [];
+export function fetchExistingEvents(ctx: Ctx): ExistingEvent[] {
+ const events: ExistingEvent[] = [];
ctx.store.forEachRow("events", (rowId, _forEachCell) => {
const event = ctx.store.getRow("events", rowId);
@@ -20,6 +20,7 @@ export function fetchExistingEvents(ctx: Ctx): Array {
if (eventDate >= ctx.from && eventDate <= ctx.to) {
events.push({
id: rowId,
+ tracking_id_event: event.tracking_id_event as string | undefined,
user_id: event.user_id as string | undefined,
created_at: event.created_at as string | undefined,
calendar_id: calendarId,
diff --git a/apps/desktop/src/services/apple-calendar/fetch/incoming.ts b/apps/desktop/src/services/apple-calendar/fetch/incoming.ts
index ecf31773d4..419def85fb 100644
--- a/apps/desktop/src/services/apple-calendar/fetch/incoming.ts
+++ b/apps/desktop/src/services/apple-calendar/fetch/incoming.ts
@@ -6,13 +6,13 @@ import type { EventParticipant } from "@hypr/store";
import type { Ctx } from "../ctx";
import type { IncomingEvent } from "./types";
-export async function fetchIncomingEvents(
- ctx: Ctx,
-): Promise> {
+export async function fetchIncomingEvents(ctx: Ctx): Promise {
+ const trackingIds = Array.from(ctx.calendarTrackingIdToId.keys());
+
const results = await Promise.all(
- Array.from(ctx.calendarIds).map(async (calendarId) => {
+ trackingIds.map(async (trackingId) => {
const result = await appleCalendarCommands.listEvents({
- calendar_tracking_id: calendarId,
+ calendar_tracking_id: trackingId,
from: ctx.from.toISOString(),
to: ctx.to.toISOString(),
});
@@ -45,8 +45,8 @@ async function normalizeAppleEvent(event: AppleEvent): Promise {
}
return {
- id: event.event_identifier,
- calendar_id: event.calendar.id,
+ tracking_id_event: event.event_identifier,
+ tracking_id_calendar: event.calendar.id,
title: event.title,
started_at: event.start_date,
ended_at: event.end_date,
diff --git a/apps/desktop/src/services/apple-calendar/fetch/types.ts b/apps/desktop/src/services/apple-calendar/fetch/types.ts
index fd87fec849..3b63a19d13 100644
--- a/apps/desktop/src/services/apple-calendar/fetch/types.ts
+++ b/apps/desktop/src/services/apple-calendar/fetch/types.ts
@@ -1,8 +1,18 @@
import { EventStorage } from "@hypr/store";
-type EventBaseForSync = { id: string };
+export type IncomingEvent = {
+ tracking_id_event: string;
+ tracking_id_calendar: string;
+ title?: string;
+ started_at?: string;
+ ended_at?: string;
+ location?: string;
+ meeting_link?: string;
+ description?: string;
+ participants?: string;
+};
-export type IncomingEvent = EventBaseForSync &
- Omit;
-
-export type ExistingEvent = EventBaseForSync & EventStorage;
+export type ExistingEvent = {
+ id: string;
+ tracking_id_event?: string;
+} & EventStorage;
diff --git a/apps/desktop/src/services/apple-calendar/index.ts b/apps/desktop/src/services/apple-calendar/index.ts
index 24a3d65aba..a2fabd35d8 100644
--- a/apps/desktop/src/services/apple-calendar/index.ts
+++ b/apps/desktop/src/services/apple-calendar/index.ts
@@ -3,7 +3,12 @@ import type { Queries } from "tinybase/with-schemas";
import type { Schemas, Store } from "../../store/tinybase/main";
import { createCtx } from "./ctx";
import { fetchExistingEvents, fetchIncomingEvents } from "./fetch";
-import { execute, sync } from "./process";
+import {
+ executeForEventsSync,
+ executeForParticipantsSync,
+ syncEvents,
+ syncParticipants,
+} from "./process";
export const CALENDAR_SYNC_TASK_ID = "calendarSync";
@@ -26,6 +31,11 @@ async function run(store: Store, queries: Queries) {
const incoming = await fetchIncomingEvents(ctx);
const existing = fetchExistingEvents(ctx);
- const out = sync(ctx, { incoming, existing });
- execute(ctx.store, out);
+ const out = syncEvents(ctx, { incoming, existing });
+ const { addedEventIds } = executeForEventsSync(ctx, out);
+
+ const participantsOut = syncParticipants(ctx, {
+ eventIds: [...out.toUpdate.map((e) => e.id), ...addedEventIds],
+ });
+ executeForParticipantsSync(ctx, participantsOut);
}
diff --git a/apps/desktop/src/services/apple-calendar/process/events/execute.ts b/apps/desktop/src/services/apple-calendar/process/events/execute.ts
new file mode 100644
index 0000000000..c1d3fb81aa
--- /dev/null
+++ b/apps/desktop/src/services/apple-calendar/process/events/execute.ts
@@ -0,0 +1,66 @@
+import type { EventStorage } from "@hypr/store";
+
+import { id } from "../../../../utils";
+import type { Ctx } from "../../ctx";
+import type { EventsSyncOutput } from "./types";
+
+export function executeForEventsSync(
+ ctx: Ctx,
+ out: EventsSyncOutput,
+): { addedEventIds: string[] } {
+ const userId = ctx.store.getValue("user_id");
+ if (!userId) {
+ throw new Error("user_id is not set");
+ }
+
+ const now = new Date().toISOString();
+ const addedEventIds: string[] = [];
+
+ ctx.store.transaction(() => {
+ for (const eventId of out.toDelete) {
+ ctx.store.delRow("events", eventId);
+ }
+
+ for (const event of out.toUpdate) {
+ ctx.store.setPartialRow("events", event.id, {
+ tracking_id_event: event.tracking_id_event,
+ calendar_id: event.calendar_id,
+ title: event.title,
+ started_at: event.started_at,
+ ended_at: event.ended_at,
+ location: event.location,
+ meeting_link: event.meeting_link,
+ description: event.description,
+ participants: event.participants,
+ });
+ }
+
+ for (const incomingEvent of out.toAdd) {
+ const calendarId = ctx.calendarTrackingIdToId.get(
+ incomingEvent.tracking_id_calendar,
+ );
+ if (!calendarId) {
+ continue;
+ }
+
+ const eventId = id();
+ addedEventIds.push(eventId);
+
+ ctx.store.setRow("events", eventId, {
+ user_id: userId,
+ created_at: now,
+ tracking_id_event: incomingEvent.tracking_id_event,
+ calendar_id: calendarId,
+ title: incomingEvent.title ?? "",
+ started_at: incomingEvent.started_at ?? "",
+ ended_at: incomingEvent.ended_at ?? "",
+ location: incomingEvent.location,
+ meeting_link: incomingEvent.meeting_link,
+ description: incomingEvent.description,
+ participants: incomingEvent.participants,
+ } satisfies EventStorage);
+ }
+ });
+
+ return { addedEventIds };
+}
diff --git a/apps/desktop/src/services/apple-calendar/process/events/index.ts b/apps/desktop/src/services/apple-calendar/process/events/index.ts
new file mode 100644
index 0000000000..a8ede26d54
--- /dev/null
+++ b/apps/desktop/src/services/apple-calendar/process/events/index.ts
@@ -0,0 +1,8 @@
+export { executeForEventsSync } from "./execute";
+export { syncEvents } from "./sync";
+export type {
+ EventId,
+ EventsSyncInput,
+ EventsSyncOutput,
+ EventToUpdate,
+} from "./types";
diff --git a/apps/desktop/src/services/apple-calendar/process/sync.ts b/apps/desktop/src/services/apple-calendar/process/events/sync.ts
similarity index 51%
rename from apps/desktop/src/services/apple-calendar/process/sync.ts
rename to apps/desktop/src/services/apple-calendar/process/events/sync.ts
index f88b7611ab..f900e331aa 100644
--- a/apps/desktop/src/services/apple-calendar/process/sync.ts
+++ b/apps/desktop/src/services/apple-calendar/process/events/sync.ts
@@ -1,17 +1,22 @@
-import type { Ctx } from "../ctx";
-import type { ExistingEvent, IncomingEvent } from "../fetch/types";
-import type { SyncInput, SyncOutput } from "./types";
-import { getSessionForEvent, isSessionEmpty } from "./utils";
-
-export function sync(ctx: Ctx, { incoming, existing }: SyncInput): SyncOutput {
- const out: SyncOutput = {
+import type { Ctx } from "../../ctx";
+import type { ExistingEvent, IncomingEvent } from "../../fetch/types";
+import { getSessionForEvent, isSessionEmpty } from "../utils";
+import type { EventsSyncInput, EventsSyncOutput } from "./types";
+
+export function syncEvents(
+ ctx: Ctx,
+ { incoming, existing }: EventsSyncInput,
+): EventsSyncOutput {
+ const out: EventsSyncOutput = {
toDelete: [],
toUpdate: [],
toAdd: [],
};
- const incomingEventMap = new Map(incoming.map((e) => [e.id, e]));
- const handledIncomingEventIds = new Set();
+ const incomingEventMap = new Map(
+ incoming.map((e) => [e.tracking_id_event, e]),
+ );
+ const handledTrackingIds = new Set();
for (const storeEvent of existing) {
const sessionId = getSessionForEvent(ctx.store, storeEvent.id);
@@ -25,16 +30,22 @@ export function sync(ctx: Ctx, { incoming, existing }: SyncInput): SyncOutput {
continue;
}
- const matchingIncomingEvent = incomingEventMap.get(storeEvent.id);
+ const trackingId = storeEvent.tracking_id_event;
+ const matchingIncomingEvent = trackingId
+ ? incomingEventMap.get(trackingId)
+ : undefined;
- if (matchingIncomingEvent) {
+ if (matchingIncomingEvent && trackingId) {
out.toUpdate.push({
+ ...storeEvent,
...matchingIncomingEvent,
id: storeEvent.id,
+ tracking_id_event: trackingId,
user_id: storeEvent.user_id,
created_at: storeEvent.created_at,
+ calendar_id: storeEvent.calendar_id,
});
- handledIncomingEventIds.add(matchingIncomingEvent.id);
+ handledTrackingIds.add(matchingIncomingEvent.tracking_id_event);
continue;
}
@@ -42,16 +53,22 @@ export function sync(ctx: Ctx, { incoming, existing }: SyncInput): SyncOutput {
continue;
}
- const rescheduledEvent = findRescheduledEvent(storeEvent, incoming);
+ const rescheduledEvent = findRescheduledEvent(ctx, storeEvent, incoming);
- if (rescheduledEvent && !handledIncomingEventIds.has(rescheduledEvent.id)) {
+ if (
+ rescheduledEvent &&
+ !handledTrackingIds.has(rescheduledEvent.tracking_id_event)
+ ) {
out.toUpdate.push({
+ ...storeEvent,
...rescheduledEvent,
id: storeEvent.id,
+ tracking_id_event: rescheduledEvent.tracking_id_event,
user_id: storeEvent.user_id,
created_at: storeEvent.created_at,
+ calendar_id: storeEvent.calendar_id,
});
- handledIncomingEventIds.add(rescheduledEvent.id);
+ handledTrackingIds.add(rescheduledEvent.tracking_id_event);
continue;
}
@@ -59,7 +76,7 @@ export function sync(ctx: Ctx, { incoming, existing }: SyncInput): SyncOutput {
}
for (const incomingEvent of incoming) {
- if (!handledIncomingEventIds.has(incomingEvent.id)) {
+ if (!handledTrackingIds.has(incomingEvent.tracking_id_event)) {
out.toAdd.push(incomingEvent);
}
}
@@ -68,8 +85,9 @@ export function sync(ctx: Ctx, { incoming, existing }: SyncInput): SyncOutput {
}
function findRescheduledEvent(
+ ctx: Ctx,
storeEvent: ExistingEvent,
- incomingEvents: Array,
+ incomingEvents: IncomingEvent[],
): IncomingEvent | undefined {
if (!storeEvent.started_at) {
return undefined;
@@ -86,7 +104,10 @@ function findRescheduledEvent(
return false;
}
- if (incoming.calendar_id !== storeEvent.calendar_id) {
+ const incomingCalendarId = ctx.calendarTrackingIdToId.get(
+ incoming.tracking_id_calendar,
+ );
+ if (incomingCalendarId !== storeEvent.calendar_id) {
return false;
}
@@ -99,7 +120,7 @@ function findRescheduledEvent(
return false;
}
- if (incoming.id === storeEvent.id) {
+ if (incoming.tracking_id_event === storeEvent.tracking_id_event) {
return false;
}
diff --git a/apps/desktop/src/services/apple-calendar/process/events/types.ts b/apps/desktop/src/services/apple-calendar/process/events/types.ts
new file mode 100644
index 0000000000..5c0652a084
--- /dev/null
+++ b/apps/desktop/src/services/apple-calendar/process/events/types.ts
@@ -0,0 +1,17 @@
+import type { ExistingEvent, IncomingEvent } from "../../fetch/types";
+
+export type EventId = string;
+
+export type EventsSyncInput = {
+ incoming: IncomingEvent[];
+ existing: ExistingEvent[];
+};
+
+export type EventToUpdate = ExistingEvent &
+ Omit;
+
+export type EventsSyncOutput = {
+ toDelete: EventId[];
+ toUpdate: EventToUpdate[];
+ toAdd: IncomingEvent[];
+};
diff --git a/apps/desktop/src/services/apple-calendar/process/execute.ts b/apps/desktop/src/services/apple-calendar/process/execute.ts
deleted file mode 100644
index 115d31a61e..0000000000
--- a/apps/desktop/src/services/apple-calendar/process/execute.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { EventStorage } from "@hypr/store";
-
-import type { Store } from "../../../store/tinybase/main";
-import { id } from "../../../utils";
-import type { SyncOutput } from "./types";
-
-export function execute(store: Store, syncOutput: SyncOutput): void {
- const userId = store.getValue("user_id");
- if (!userId) {
- throw new Error("user_id is not set");
- }
-
- const now = new Date().toISOString();
-
- store.transaction(() => {
- for (const eventId of syncOutput.toDelete) {
- store.delRow("events", eventId);
- }
-
- for (const existingEvent of syncOutput.toUpdate) {
- store.setPartialRow("events", existingEvent.id, existingEvent);
- }
-
- for (const incomingEvent of syncOutput.toAdd) {
- store.setRow("events", id(), {
- ...incomingEvent,
- user_id: userId,
- created_at: now,
- } satisfies EventStorage);
- }
- });
-}
diff --git a/apps/desktop/src/services/apple-calendar/process/index.ts b/apps/desktop/src/services/apple-calendar/process/index.ts
index 3559b11aaf..dd4f982d00 100644
--- a/apps/desktop/src/services/apple-calendar/process/index.ts
+++ b/apps/desktop/src/services/apple-calendar/process/index.ts
@@ -1,2 +1,2 @@
-export { execute } from "./execute";
-export { sync } from "./sync";
+export { executeForEventsSync, syncEvents } from "./events";
+export { executeForParticipantsSync, syncParticipants } from "./participants";
diff --git a/apps/desktop/src/services/apple-calendar/process/participants/execute.ts b/apps/desktop/src/services/apple-calendar/process/participants/execute.ts
new file mode 100644
index 0000000000..bcee7a1d08
--- /dev/null
+++ b/apps/desktop/src/services/apple-calendar/process/participants/execute.ts
@@ -0,0 +1,50 @@
+import type {
+ HumanStorage,
+ MappingSessionParticipantStorage,
+} from "@hypr/store";
+
+import { id } from "../../../../utils";
+import type { Ctx } from "../../ctx";
+import type { ParticipantsSyncOutput } from "./types";
+
+export function executeForParticipantsSync(
+ ctx: Ctx,
+ out: ParticipantsSyncOutput,
+): void {
+ const userId = ctx.store.getValue("user_id");
+ if (!userId) {
+ return;
+ }
+
+ const now = new Date().toISOString();
+
+ ctx.store.transaction(() => {
+ for (const human of out.humansToCreate) {
+ ctx.store.setRow("humans", human.id, {
+ user_id: String(userId),
+ created_at: now,
+ name: human.name,
+ email: human.email,
+ org_id: "",
+ job_title: "",
+ linkedin_username: "",
+ is_user: false,
+ memo: "",
+ } satisfies HumanStorage);
+ }
+
+ for (const mappingId of out.toDelete) {
+ ctx.store.delRow("mapping_session_participant", mappingId);
+ }
+
+ for (const mapping of out.toAdd) {
+ ctx.store.setRow("mapping_session_participant", id(), {
+ user_id: String(userId),
+ created_at: now,
+ session_id: mapping.sessionId,
+ human_id: mapping.humanId,
+ source: "auto",
+ } satisfies MappingSessionParticipantStorage);
+ }
+ });
+}
diff --git a/apps/desktop/src/services/apple-calendar/process/participants/index.ts b/apps/desktop/src/services/apple-calendar/process/participants/index.ts
new file mode 100644
index 0000000000..1f2c3c347d
--- /dev/null
+++ b/apps/desktop/src/services/apple-calendar/process/participants/index.ts
@@ -0,0 +1,9 @@
+export { executeForParticipantsSync } from "./execute";
+export { syncParticipants } from "./sync";
+export type {
+ HumanToCreate,
+ ParticipantMappingId,
+ ParticipantMappingToAdd,
+ ParticipantsSyncInput,
+ ParticipantsSyncOutput,
+} from "./types";
diff --git a/apps/desktop/src/services/apple-calendar/process/participants/sync.ts b/apps/desktop/src/services/apple-calendar/process/participants/sync.ts
new file mode 100644
index 0000000000..b208e8b53b
--- /dev/null
+++ b/apps/desktop/src/services/apple-calendar/process/participants/sync.ts
@@ -0,0 +1,164 @@
+import type { EventParticipant } from "@hypr/store";
+
+import type { Store } from "../../../../store/tinybase/main";
+import { id } from "../../../../utils";
+import type { Ctx } from "../../ctx";
+import { getSessionForEvent } from "../utils";
+import type {
+ HumanToCreate,
+ ParticipantMappingToAdd,
+ ParticipantsSyncInput,
+ ParticipantsSyncOutput,
+} from "./types";
+
+export function syncParticipants(
+ ctx: Ctx,
+ input: ParticipantsSyncInput,
+): ParticipantsSyncOutput {
+ const output: ParticipantsSyncOutput = {
+ toDelete: [],
+ toAdd: [],
+ humansToCreate: [],
+ };
+
+ const humansByEmail = buildHumansByEmailIndex(ctx.store);
+ const humansToCreateMap = new Map();
+
+ for (const eventId of input.eventIds) {
+ const sessionId = getSessionForEvent(ctx.store, eventId);
+ if (!sessionId) {
+ continue;
+ }
+
+ const eventParticipants = getEventParticipants(ctx.store, eventId);
+ const sessionOutput = computeSessionParticipantChanges(
+ ctx.store,
+ sessionId,
+ eventParticipants,
+ humansByEmail,
+ humansToCreateMap,
+ );
+
+ output.toDelete.push(...sessionOutput.toDelete);
+ output.toAdd.push(...sessionOutput.toAdd);
+ }
+
+ output.humansToCreate = Array.from(humansToCreateMap.values());
+
+ return output;
+}
+
+function buildHumansByEmailIndex(store: Store): Map {
+ const humansByEmail = new Map();
+
+ store.forEachRow("humans", (humanId, _forEachCell) => {
+ const human = store.getRow("humans", humanId);
+ const email = human?.email;
+ if (email && typeof email === "string" && email.trim()) {
+ humansByEmail.set(email.toLowerCase(), humanId);
+ }
+ });
+
+ return humansByEmail;
+}
+
+function getEventParticipants(
+ store: Store,
+ eventId: string,
+): EventParticipant[] {
+ const event = store.getRow("events", eventId);
+ if (!event?.participants) {
+ return [];
+ }
+
+ try {
+ const parsed = JSON.parse(String(event.participants));
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+function computeSessionParticipantChanges(
+ store: Store,
+ sessionId: string,
+ eventParticipants: EventParticipant[],
+ humansByEmail: Map,
+ humansToCreateMap: Map,
+): { toDelete: string[]; toAdd: ParticipantMappingToAdd[] } {
+ const eventHumanIds = new Set();
+ for (const participant of eventParticipants) {
+ if (!participant.email) {
+ continue;
+ }
+
+ const emailLower = participant.email.toLowerCase();
+ let humanId = humansByEmail.get(emailLower);
+
+ if (!humanId) {
+ const existing = humansToCreateMap.get(emailLower);
+ if (existing) {
+ humanId = existing.id;
+ } else {
+ humanId = id();
+ humansToCreateMap.set(emailLower, {
+ id: humanId,
+ name: participant.name || participant.email,
+ email: participant.email,
+ });
+ humansByEmail.set(emailLower, humanId);
+ }
+ }
+
+ eventHumanIds.add(humanId);
+ }
+
+ const existingMappings = getExistingMappings(store, sessionId);
+
+ const toAdd: ParticipantMappingToAdd[] = [];
+ const toDelete: string[] = [];
+
+ for (const humanId of eventHumanIds) {
+ const existing = existingMappings.get(humanId);
+ if (!existing) {
+ toAdd.push({ sessionId, humanId });
+ } else if (existing.source === "excluded") {
+ continue;
+ }
+ }
+
+ for (const [humanId, mapping] of existingMappings) {
+ if (mapping.source === "auto" && !eventHumanIds.has(humanId)) {
+ toDelete.push(mapping.id);
+ }
+ }
+
+ return { toDelete, toAdd };
+}
+
+type MappingInfo = {
+ id: string;
+ humanId: string;
+ source: string | undefined;
+};
+
+function getExistingMappings(
+ store: Store,
+ sessionId: string,
+): Map {
+ const mappings = new Map();
+
+ store.forEachRow("mapping_session_participant", (mappingId, _forEachCell) => {
+ const mapping = store.getRow("mapping_session_participant", mappingId);
+ if (mapping?.session_id === sessionId) {
+ const humanId = String(mapping.human_id);
+ mappings.set(humanId, {
+ id: mappingId,
+ humanId,
+ source: mapping.source as string | undefined,
+ });
+ }
+ });
+
+ return mappings;
+}
diff --git a/apps/desktop/src/services/apple-calendar/process/participants/types.ts b/apps/desktop/src/services/apple-calendar/process/participants/types.ts
new file mode 100644
index 0000000000..5447c85a3a
--- /dev/null
+++ b/apps/desktop/src/services/apple-calendar/process/participants/types.ts
@@ -0,0 +1,22 @@
+export type ParticipantMappingId = string;
+
+export type ParticipantsSyncInput = {
+ eventIds: string[];
+};
+
+export type ParticipantMappingToAdd = {
+ sessionId: string;
+ humanId: string;
+};
+
+export type HumanToCreate = {
+ id: string;
+ name: string;
+ email: string;
+};
+
+export type ParticipantsSyncOutput = {
+ toDelete: ParticipantMappingId[];
+ toAdd: ParticipantMappingToAdd[];
+ humansToCreate: HumanToCreate[];
+};
diff --git a/apps/desktop/src/services/apple-calendar/process/types.ts b/apps/desktop/src/services/apple-calendar/process/types.ts
deleted file mode 100644
index eefb8b4811..0000000000
--- a/apps/desktop/src/services/apple-calendar/process/types.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import type { ExistingEvent, IncomingEvent } from "../fetch/types";
-
-export type SyncInput = {
- incoming: Array;
- existing: Array;
-};
-
-export type SyncOutput = {
- toDelete: string[];
- toUpdate: Array;
- toAdd: Array;
-};
diff --git a/apps/desktop/src/utils/calendar.ts b/apps/desktop/src/utils/calendar.ts
new file mode 100644
index 0000000000..538ed01af4
--- /dev/null
+++ b/apps/desktop/src/utils/calendar.ts
@@ -0,0 +1,20 @@
+import type { Store } from "tinybase/with-schemas";
+
+import type { Schemas } from "../store/tinybase/main";
+
+export function findCalendarByTrackingId(
+ store: Store,
+ trackingId: string,
+): string | null {
+ let foundRowId: string | null = null;
+
+ store.forEachRow("calendars", (rowId, _forEachCell) => {
+ if (foundRowId) return;
+ const row = store.getRow("calendars", rowId);
+ if (row?.tracking_id_calendar === trackingId) {
+ foundRowId = rowId;
+ }
+ });
+
+ return foundRowId;
+}
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index 797ee8d107..77dfcc8ef8 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -187,6 +187,7 @@ export const events = pgTable(
TABLE_EVENTS,
{
...SHARED,
+ tracking_id_event: text("tracking_id_event").notNull(),
calendar_id: uuid("calendar_id")
.notNull()
.references(() => calendars.id, { onDelete: "cascade" }),
@@ -206,6 +207,7 @@ export const calendars = pgTable(
TABLE_CALENDARS,
{
...SHARED,
+ tracking_id_calendar: text("tracking_id_calendar").notNull(),
name: text("name").notNull(),
},
(table) => createPolicies(TABLE_CALENDARS, table.user_id),
diff --git a/packages/store/src/schema-external.ts b/packages/store/src/schema-external.ts
index 3439e1ac1c..98d6340858 100644
--- a/packages/store/src/schema-external.ts
+++ b/packages/store/src/schema-external.ts
@@ -90,9 +90,16 @@ export const transcriptSchema = baseTranscriptSchema.omit({ id: true }).extend({
ended_at: z.preprocess((val) => val ?? undefined, z.number().optional()),
});
+export const participantSourceSchema = z.enum(["manual", "auto", "excluded"]);
+export type ParticipantSource = z.infer;
+
export const mappingSessionParticipantSchema =
baseMappingSessionParticipantSchema.omit({ id: true }).extend({
created_at: z.string(),
+ source: z.preprocess(
+ (val) => val ?? undefined,
+ participantSourceSchema.optional(),
+ ),
});
export const tagSchema = baseTagSchema.omit({ id: true }).extend({
@@ -220,6 +227,9 @@ export type FolderStorage = ToStorageType;
export type PromptStorage = ToStorageType;
export type ChatShortcutStorage = ToStorageType;
export type EventStorage = ToStorageType;
+export type MappingSessionParticipantStorage = ToStorageType<
+ typeof mappingSessionParticipantSchema
+>;
export const externalTableSchemaForTinybase = {
folders: {
@@ -282,6 +292,7 @@ export const externalTableSchemaForTinybase = {
calendars: {
user_id: { type: "string" },
created_at: { type: "string" },
+ tracking_id_calendar: { type: "string" },
name: { type: "string" },
enabled: { type: "boolean" },
provider: { type: "string" },
@@ -291,6 +302,7 @@ export const externalTableSchemaForTinybase = {
events: {
user_id: { type: "string" },
created_at: { type: "string" },
+ tracking_id_event: { type: "string" },
calendar_id: { type: "string" },
title: { type: "string" },
started_at: { type: "string" },
@@ -306,6 +318,7 @@ export const externalTableSchemaForTinybase = {
created_at: { type: "string" },
session_id: { type: "string" },
human_id: { type: "string" },
+ source: { type: "string" },
} as const satisfies InferTinyBaseSchema<
typeof mappingSessionParticipantSchema
>,