diff --git a/apps/desktop2/package.json b/apps/desktop2/package.json index be1792e40..4579ae3df 100644 --- a/apps/desktop2/package.json +++ b/apps/desktop2/package.json @@ -14,6 +14,7 @@ "dependencies": { "@electric-sql/client": "^1.0.14", "@hypr/db": "workspace:*", + "@hypr/plugin-analytics": "workspace:*", "@hypr/plugin-db2": "workspace:*", "@hypr/plugin-windows": "workspace:*", "@hypr/tiptap": "workspace:^", @@ -22,6 +23,7 @@ "@sentry/react": "^8.55.0", "@supabase/supabase-js": "^2.74.0", "@t3-oss/env-core": "^0.13.8", + "@tanstack/react-query": "^5.90.2", "@tanstack/react-router": "^1.132.47", "@tanstack/react-virtual": "^3.13.12", "@tauri-apps/api": "^2.8.0", @@ -30,6 +32,7 @@ "@tauri-apps/plugin-process": "^2.3.0", "@tauri-apps/plugin-store": "^2.4.0", "@tauri-apps/plugin-updater": "^2.9.0", + "@wavesurfer/react": "^1.0.11", "@xstate/store": "^3.9.3", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -39,6 +42,7 @@ "react-dom": "^19.2.0", "react-hotkeys-hook": "^4.6.2", "tinybase": "^6.7.0", + "wavesurfer.js": "^7.11.0", "zod": "^4.1.12", "zustand": "^5.0.8" }, diff --git a/apps/desktop2/src-tauri/src/lib.rs b/apps/desktop2/src-tauri/src/lib.rs index dcb46c416..4899b86cb 100644 --- a/apps/desktop2/src-tauri/src/lib.rs +++ b/apps/desktop2/src-tauri/src/lib.rs @@ -42,6 +42,7 @@ pub async fn main() { .plugin(tauri_plugin_analytics::init()) .plugin(tauri_plugin_db2::init()) .plugin(tauri_plugin_tracing::init()) + .plugin(tauri_plugin_analytics::init()) .plugin(tauri_plugin_listener::init()) .plugin(tauri_plugin_local_stt::init()) .plugin(tauri_plugin_updater::Builder::new().build()) diff --git a/apps/desktop2/src-tauri/tauri.conf.json b/apps/desktop2/src-tauri/tauri.conf.json index 00d1d7405..21238c111 100644 --- a/apps/desktop2/src-tauri/tauri.conf.json +++ b/apps/desktop2/src-tauri/tauri.conf.json @@ -6,7 +6,7 @@ "identifier": "com.hyprnote2.dev", "build": { "beforeDevCommand": "pnpm -F desktop2 dev", - "devUrl": "http://localhost:1420", + "devUrl": "http://localhost:1422", "beforeBuildCommand": "pnpm -F desktop2 build", "frontendDist": "../dist" }, diff --git a/apps/desktop2/src/components/main/body/contacts/details.tsx b/apps/desktop2/src/components/main/body/contacts/details.tsx new file mode 100644 index 000000000..efe906086 --- /dev/null +++ b/apps/desktop2/src/components/main/body/contacts/details.tsx @@ -0,0 +1,515 @@ +import { Building2, CircleMinus, FileText, Pencil, SearchIcon, TrashIcon, UserPlus } from "lucide-react"; +import React, { useState } from "react"; + +import { Button } from "@hypr/ui/components/ui/button"; +import { Input } from "@hypr/ui/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@hypr/ui/components/ui/popover"; +import * as persisted from "../../../../store/tinybase/persisted"; + +export function DetailsColumn({ + selectedPersonData, + editingPerson, + setEditingPerson, + organizations, + personSessions, + handleEditPerson, + handleDeletePerson, + handleSessionClick, + getInitials, +}: { + selectedPersonData: any; + editingPerson: string | null; + setEditingPerson: (id: string | null) => void; + organizations: any[]; + personSessions: any[]; + handleEditPerson: (id: string) => void; + handleDeletePerson: (id: string) => void; + handleSessionClick: (id: string) => void; + getInitials: (name: string | null) => string; +}) { + return ( +
+ {selectedPersonData + ? ( + editingPerson === selectedPersonData.id + ? ( + setEditingPerson(null)} + onCancel={() => setEditingPerson(null)} + /> + ) + : ( + <> +
+
+
+ + {getInitials(selectedPersonData.name || selectedPersonData.email)} + +
+
+
+
+

+ {selectedPersonData.name || "Unnamed Contact"} + {selectedPersonData.is_user && ( + You + )} +

+ {selectedPersonData.job_title && ( +

{selectedPersonData.job_title}

+ )} + {selectedPersonData.email && ( +

{selectedPersonData.email}

+ )} + {selectedPersonData.org_id && } + {!selectedPersonData.is_user && selectedPersonData.email && ( + + )} +
+
+ + {!selectedPersonData.is_user && ( + + )} +
+
+
+
+
+ +
+

Related Notes

+
+
+ {personSessions.length > 0 + ? ( + personSessions.map((session: any) => ( + + )) + ) + :

No related notes found

} +
+
+
+ + ) + ) + : ( +
+

Select a person to view details

+
+ )} +
+ ); +} + +function OrganizationInfo({ organizationId }: { organizationId: string }) { + const organization = persisted.UI.useRow("organizations", organizationId, persisted.STORE_ID); + + if (!organization) { + return null; + } + + return ( +

+ {organization.name} +

+ ); +} + +function EditPersonForm({ + person, + organizations: _organizations, + onSave, + onCancel, +}: { + person: any; + organizations: any[]; + onSave: () => void; + onCancel: () => void; +}) { + const personData = persisted.UI.useRow("humans", person.id, persisted.STORE_ID); + + const getInitials = (name: string | null) => { + if (!name) { + return "?"; + } + return name + .split(" ") + .map(n => n[0]) + .join("") + .toUpperCase() + .slice(0, 2); + }; + + if (!personData) { + return null; + } + + return ( +
+
+
+

Edit Contact

+
+ + +
+
+
+ +
+
+
+ + {getInitials(personData.name as string || "?")} + +
+
+ +
+ + + +
+
Company
+
+ +
+
+ + + +
+
+
+ ); +} + +function EditPersonNameField({ personId }: { personId: string }) { + const value = persisted.UI.useCell("humans", personId, "name", persisted.STORE_ID); + + const handleChange = persisted.UI.useSetCellCallback( + "humans", + personId, + "name", + (e: React.ChangeEvent) => e.target.value, + [], + persisted.STORE_ID, + ); + + return ( +
+
Name
+
+ +
+
+ ); +} + +function EditPersonJobTitleField({ personId }: { personId: string }) { + const value = persisted.UI.useCell("humans", personId, "job_title", persisted.STORE_ID); + + const handleChange = persisted.UI.useSetCellCallback( + "humans", + personId, + "job_title", + (e: React.ChangeEvent) => e.target.value, + [], + persisted.STORE_ID, + ); + + return ( +
+
Job Title
+
+ +
+
+ ); +} + +function EditPersonEmailField({ personId }: { personId: string }) { + const value = persisted.UI.useCell("humans", personId, "email", persisted.STORE_ID); + + const handleChange = persisted.UI.useSetCellCallback( + "humans", + personId, + "email", + (e: React.ChangeEvent) => e.target.value, + [], + persisted.STORE_ID, + ); + + return ( +
+
Email
+
+ +
+
+ ); +} + +function EditPersonLinkedInField({ personId }: { personId: string }) { + const value = persisted.UI.useCell("humans", personId, "linkedin_username", persisted.STORE_ID); + + const handleChange = persisted.UI.useSetCellCallback( + "humans", + personId, + "linkedin_username", + (e: React.ChangeEvent) => e.target.value, + [], + persisted.STORE_ID, + ); + + return ( +
+
LinkedIn
+
+ +
+
+ ); +} + +function EditPersonOrganizationSelector({ personId }: { personId: string }) { + const [open, setOpen] = useState(false); + const orgId = persisted.UI.useCell("humans", personId, "org_id", persisted.STORE_ID) as string | null; + const organization = persisted.UI.useRow("organizations", orgId ?? "", persisted.STORE_ID); + + const handleChange = persisted.UI.useSetCellCallback( + "humans", + personId, + "org_id", + (newOrgId: string | null) => newOrgId ?? "", + [], + persisted.STORE_ID, + ); + + const handleRemoveOrganization = () => { + handleChange(null); + }; + + return ( + + +
+ {organization + ? ( +
+ {organization.name} + + { + e.stopPropagation(); + handleRemoveOrganization(); + }} + /> + +
+ ) + : Select organization} +
+
+ + + setOpen(false)} /> + +
+ ); +} + +function OrganizationControl({ + onChange, + closePopover, +}: { + onChange: (orgId: string | null) => void; + closePopover: () => void; +}) { + const [searchTerm, setSearchTerm] = useState(""); + + const organizationsData = persisted.UI.useResultTable(persisted.QUERIES.visibleOrganizations, persisted.STORE_ID); + + const allOrganizations = Object.entries(organizationsData).map(([id, data]) => ({ + id, + ...(data as any), + })); + + const organizations = searchTerm.trim() + ? allOrganizations.filter((org: any) => org.name.toLowerCase().includes(searchTerm.toLowerCase())) + : allOrganizations; + + const handleSubmit = (e: React.SyntheticEvent) => { + e.preventDefault(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + } + }; + + const selectOrganization = (orgId: string) => { + onChange(orgId); + closePopover(); + }; + + return ( +
+
Organization
+ +
+
+
+ + + + setSearchTerm(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search or add company" + className="w-full bg-transparent text-sm focus:outline-none placeholder:text-gray-400 focus-visible:ring-0 focus-visible:ring-offset-0" + /> +
+ + {searchTerm.trim() && ( +
+ {organizations.map((org: any) => ( + + ))} + + {organizations.length === 0 && ( + + )} +
+ )} + + {!searchTerm.trim() && organizations.length > 0 && ( +
+ {organizations.map((org: any) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/desktop2/src/components/main/body/contacts/index.tsx b/apps/desktop2/src/components/main/body/contacts/index.tsx new file mode 100644 index 000000000..cba691924 --- /dev/null +++ b/apps/desktop2/src/components/main/body/contacts/index.tsx @@ -0,0 +1,171 @@ +import { Contact2Icon } from "lucide-react"; + +import * as persisted from "../../../../store/tinybase/persisted"; +import { type Tab, useTabs } from "../../../../store/zustand/tabs"; +import { type TabItem, TabItemBase } from "../shared"; +import { DetailsColumn } from "./details"; +import { OrganizationsColumn } from "./organizations"; +import { PeopleColumn } from "./people"; + +export const TabItemContact: TabItem = ({ tab, handleClose, handleSelect }) => { + return ( + } + title={"Contacts"} + active={tab.active} + handleClose={() => handleClose(tab)} + handleSelect={() => handleSelect(tab)} + /> + ); +}; + +export function TabContentContact({ tab }: { tab: Tab }) { + if (tab.type !== "contacts") { + return null; + } + + return ( +
+ +
+ ); +} + +function ContactView({ tab }: { tab: Tab }) { + if (tab.type !== "contacts") { + return null; + } + + const updateContactsTabState = useTabs((state) => state.updateContactsTabState); + + const { selectedOrganization, selectedPerson, editingPerson, editingOrg, showNewOrg, sortOption } = tab.state; + + const setSelectedOrganization = (value: string | null) => { + updateContactsTabState(tab, { ...tab.state, selectedOrganization: value }); + }; + + const setSelectedPerson = (value: string | null) => { + updateContactsTabState(tab, { ...tab.state, selectedPerson: value }); + }; + + const setEditingPerson = (value: string | null) => { + updateContactsTabState(tab, { ...tab.state, editingPerson: value }); + }; + + const setEditingOrg = (value: string | null) => { + updateContactsTabState(tab, { ...tab.state, editingOrg: value }); + }; + + const setShowNewOrg = (value: boolean) => { + updateContactsTabState(tab, { ...tab.state, showNewOrg: value }); + }; + + const setSortOption = (value: "alphabetical" | "oldest" | "newest") => { + updateContactsTabState(tab, { ...tab.state, sortOption: value }); + }; + + const organizationsData = persisted.UI.useResultTable(persisted.QUERIES.visibleOrganizations, persisted.STORE_ID); + const humansData = persisted.UI.useResultTable(persisted.QUERIES.visibleHumans, persisted.STORE_ID); + const selectedPersonData = persisted.UI.useRow("humans", selectedPerson ?? "", persisted.STORE_ID); + + // Get humans by organization if one is selected + const humanIdsByOrg = persisted.UI.useSliceRowIds( + persisted.INDEXES.humansByOrg, + selectedOrganization ?? "", + persisted.STORE_ID, + ); + + // Convert to arrays for rendering + const organizations = Object.entries(organizationsData).map(([id, data]) => ({ + id, + ...(data as any), + })); + + const allHumans = Object.entries(humansData).map(([id, data]) => ({ + id, + ...(data as any), + })); + + // Filter humans by organization if selected + const displayPeople = selectedOrganization + ? allHumans.filter(h => humanIdsByOrg.includes(h.id)) + : allHumans; + + // Sort people based on selected option + const sortedPeople = [...displayPeople].sort((a: any, b: any) => { + if (sortOption === "alphabetical") { + return (a.name || a.email || "").localeCompare(b.name || b.email || ""); + } else if (sortOption === "newest") { + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); + } else { + return new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); + } + }); + + // Get sessions - for now just an empty array, we'll implement this later + const personSessions: any[] = []; + + const handleSessionClick = (_sessionId: string) => { + // Handle session click + }; + + const handleEditPerson = (personId: string) => { + setEditingPerson(personId); + }; + + const handleEditOrganization = (organizationId: string) => { + setEditingOrg(organizationId); + }; + + const handleDeletePerson = async (_personId: string) => { + // Handle delete person + }; + + const getInitials = (name: string | null) => { + if (!name) { + return "?"; + } + return name + .split(" ") + .map(n => n[0]) + .join("") + .toUpperCase() + .slice(0, 2); + }; + + return ( +
+ + + + + +
+ ); +} diff --git a/apps/desktop2/src/components/main/body/contacts/organizations.tsx b/apps/desktop2/src/components/main/body/contacts/organizations.tsx new file mode 100644 index 000000000..0fc3c6f3e --- /dev/null +++ b/apps/desktop2/src/components/main/body/contacts/organizations.tsx @@ -0,0 +1,234 @@ +import { Building2, CornerDownLeft, Pencil, Plus, User } from "lucide-react"; +import React, { useState } from "react"; + +import { cn } from "@hypr/ui/lib/utils"; +import * as persisted from "../../../../store/tinybase/persisted"; + +export function OrganizationsColumn({ + selectedOrganization, + setSelectedOrganization, + showNewOrg, + setShowNewOrg, + editingOrg, + setEditingOrg, + organizations, + handleEditOrganization, +}: { + selectedOrganization: string | null; + setSelectedOrganization: (id: string | null) => void; + showNewOrg: boolean; + setShowNewOrg: (show: boolean) => void; + editingOrg: string | null; + setEditingOrg: (id: string | null) => void; + organizations: any[]; + handleEditOrganization: (id: string) => void; +}) { + return ( +
+
+

Organizations

+ +
+
+
+ + {showNewOrg && ( + setShowNewOrg(false)} + onCancel={() => setShowNewOrg(false)} + /> + )} + {organizations.map((org: any) => + editingOrg === org.id + ? ( + setEditingOrg(null)} + onCancel={() => setEditingOrg(null)} + /> + ) + : ( +
+ + +
+ ) + )} +
+
+
+ ); +} + +function EditOrganizationForm({ + organization, + onSave, + onCancel, +}: { + organization: any; + onSave: () => void; + onCancel: () => void; +}) { + const name = persisted.UI.useCell("organizations", organization.id, "name", persisted.STORE_ID) as string; + + const handleChange = persisted.UI.useSetCellCallback( + "organizations", + organization.id, + "name", + (e: React.ChangeEvent) => e.target.value, + [], + persisted.STORE_ID, + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (name?.trim()) { + onSave(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (name?.trim()) { + onSave(); + } + } + if (e.key === "Escape") { + onCancel(); + } + }; + + return ( +
+
+
+ + {name?.trim() && ( + + )} +
+
+
+ ); +} + +function NewOrganizationForm({ + onSave, + onCancel, +}: { + onSave: () => void; + onCancel: () => void; +}) { + const [name, setName] = useState(""); + + const handleAdd = persisted.UI.useAddRowCallback( + "organizations", + () => ({ + name: name.trim(), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }), + [name], + persisted.STORE_ID, + () => { + setName(""); + onSave(); + }, + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (name.trim()) { + handleAdd(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (name.trim()) { + handleAdd(); + } + } + if (e.key === "Escape") { + onCancel(); + } + }; + + return ( +
+
+
+ setName(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Add organization" + className="w-full bg-transparent text-sm focus:outline-none placeholder:text-neutral-400" + autoFocus + /> + {name.trim() && ( + + )} +
+
+
+ ); +} diff --git a/apps/desktop2/src/components/main/body/contacts/people.tsx b/apps/desktop2/src/components/main/body/contacts/people.tsx new file mode 100644 index 000000000..9b2f46d85 --- /dev/null +++ b/apps/desktop2/src/components/main/body/contacts/people.tsx @@ -0,0 +1,85 @@ +import { Plus } from "lucide-react"; + +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@hypr/ui/components/ui/select"; +import { cn } from "@hypr/ui/lib/utils"; + +export function PeopleColumn({ + displayPeople, + selectedPerson, + setSelectedPerson, + sortOption, + setSortOption, + getInitials, +}: { + displayPeople: any[]; + selectedPerson: string | null; + setSelectedPerson: (id: string | null) => void; + sortOption: "alphabetical" | "oldest" | "newest"; + setSortOption: (option: "alphabetical" | "oldest" | "newest") => void; + getInitials: (name: string | null) => string; +}) { + return ( +
+
+

People

+
+ + +
+
+
+
+ {displayPeople.map((person: any) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/desktop2/src/components/main/body/index.tsx b/apps/desktop2/src/components/main/body/index.tsx index e6cec8333..a315fd1b6 100644 --- a/apps/desktop2/src/components/main/body/index.tsx +++ b/apps/desktop2/src/components/main/body/index.tsx @@ -1,11 +1,12 @@ import clsx from "clsx"; +import { PanelLeftOpenIcon } from "lucide-react"; import { Reorder } from "motion/react"; import { type Tab, uniqueIdfromTab, useTabs } from "../../../store/zustand/tabs"; import { useLeftSidebar } from "@hypr/utils/contexts"; -import { PanelLeftOpenIcon } from "lucide-react"; import { TabContentCalendar, TabItemCalendar } from "./calendars"; +import { TabContentContact, TabItemContact } from "./contacts"; import { TabContentEvent, TabItemEvent } from "./events"; import { TabContentFolder, TabItemFolder } from "./folders"; import { TabContentHuman, TabItemHuman } from "./humans"; @@ -81,22 +82,23 @@ function TabItem( if (tab.type === "sessions") { return ; } - if (tab.type === "events") { return ; } - - if (tab.type === "calendars") { - return ; - } if (tab.type === "folders") { return ; } - if (tab.type === "humans") { return ; } + if (tab.type === "calendars") { + return ; + } + if (tab.type === "contacts") { + return ; + } + return null; } @@ -104,22 +106,22 @@ function Content({ tab }: { tab: Tab }) { if (tab.type === "sessions") { return ; } - if (tab.type === "events") { return ; } - - if (tab.type === "calendars") { - return ; - } - if (tab.type === "folders") { return ; } - if (tab.type === "humans") { return ; } + if (tab.type === "calendars") { + return ; + } + if (tab.type === "contacts") { + return ; + } + return null; } diff --git a/apps/desktop2/src/components/main/body/shared.tsx b/apps/desktop2/src/components/main/body/shared.tsx index 87d49ce21..ae4ef1a9a 100644 --- a/apps/desktop2/src/components/main/body/shared.tsx +++ b/apps/desktop2/src/components/main/body/shared.tsx @@ -18,10 +18,10 @@ export function TabItemBase( }, ) { return ( - - + ); } diff --git a/apps/desktop2/src/components/main/sidebar/profile/banner.tsx b/apps/desktop2/src/components/main/sidebar/profile/banner.tsx index 2ed66bf01..7ee696358 100644 --- a/apps/desktop2/src/components/main/sidebar/profile/banner.tsx +++ b/apps/desktop2/src/components/main/sidebar/profile/banner.tsx @@ -8,36 +8,30 @@ export function Trial() { }, []); return ( -
-
- - Try Hyprnote Pro +
+ + Hyprnote Pro
- -
- Experience smarter meetings with free trial. +
+ Free trial for smarter meetings
-
+
+ Start Trial + +
+ ); } diff --git a/apps/desktop2/src/components/main/sidebar/profile/index.tsx b/apps/desktop2/src/components/main/sidebar/profile/index.tsx index 1543cf049..7bd701ff0 100644 --- a/apps/desktop2/src/components/main/sidebar/profile/index.tsx +++ b/apps/desktop2/src/components/main/sidebar/profile/index.tsx @@ -49,9 +49,20 @@ export function ProfileSection() { }, [openNew, closeMenu]); const handleClickContacts = useCallback(() => { - console.log("Contacts"); + openNew({ + type: "contacts", + active: true, + state: { + selectedOrganization: null, + selectedPerson: null, + editingPerson: null, + editingOrg: null, + showNewOrg: false, + sortOption: "alphabetical", + }, + }); closeMenu(); - }, [closeMenu]); + }, [openNew, closeMenu]); const handleClickDailyNote = useCallback(() => { console.log("Daily note"); diff --git a/apps/desktop2/src/store/seed.ts b/apps/desktop2/src/store/seed.ts index 3f76cf4e3..3756584c3 100644 --- a/apps/desktop2/src/store/seed.ts +++ b/apps/desktop2/src/store/seed.ts @@ -38,17 +38,39 @@ const createOrganization = () => ({ } satisfies Organization, }); -const createHuman = (org_id: string) => { +const createHuman = (org_id: string, isUser = false) => { const sex = faker.person.sexType(); const firstName = faker.person.firstName(sex); const lastName = faker.person.lastName(); + const jobTitles = [ + "Software Engineer", + "Product Manager", + "Designer", + "Engineering Manager", + "CEO", + "CTO", + "VP of Engineering", + "Data Scientist", + "Marketing Manager", + "Sales Director", + "Account Executive", + "Customer Success Manager", + "Operations Manager", + "HR Manager", + ]; + return { id: id(), data: { user_id: USER_ID, name: `${firstName} ${lastName}`, email: faker.internet.email({ firstName, lastName }), + job_title: faker.helpers.arrayElement(jobTitles), + linkedin_username: faker.datatype.boolean({ probability: 0.7 }) + ? `${firstName.toLowerCase()}${lastName.toLowerCase()}` + : undefined, + is_user: isUser, created_at: faker.date.past({ years: 1 }).toISOString(), org_id, } satisfies Human, @@ -342,6 +364,15 @@ const generateMockData = (config: MockConfig) => { }); const humanIds: string[] = []; + + // Create the current user first (in the first organization) + if (orgIds.length > 0) { + const currentUser = createHuman(orgIds[0], true); + humans[currentUser.id] = currentUser.data; + humanIds.push(currentUser.id); + } + + // Create other humans orgIds.forEach((orgId) => { const humanCount = faker.number.int({ min: config.humansPerOrg.min, @@ -349,7 +380,7 @@ const generateMockData = (config: MockConfig) => { }); Array.from({ length: humanCount }, () => { - const human = createHuman(orgId); + const human = createHuman(orgId, false); humans[human.id] = human.data; humanIds.push(human.id); }); @@ -477,8 +508,8 @@ faker.seed(123); export const V1 = (() => { const data = generateMockData({ - organizations: 5, - humansPerOrg: { min: 3, max: 8 }, + organizations: 8, + humansPerOrg: { min: 5, max: 12 }, sessionsPerHuman: { min: 2, max: 6 }, eventsPerHuman: { min: 1, max: 5 }, calendarsPerUser: 3, @@ -501,7 +532,14 @@ export const V1 = (() => { (s) => !s.event_id, ).length; + const totalHumans = Object.keys(data.humans).length; + const totalOrganizations = Object.keys(data.organizations).length; + const totalUsers = Object.values(data.humans).filter((h) => h.is_user).length; + console.log("=== Seed Data Statistics ==="); + console.log(`Total Organizations: ${totalOrganizations}`); + console.log(`Total Humans: ${totalHumans}`); + console.log(`Current User(s): ${totalUsers}`); console.log(`Total Events: ${totalEvents}`); console.log(`Total Sessions: ${totalSessions}`); console.log(`Events without Session: ${eventsWithoutSession}`); diff --git a/apps/desktop2/src/store/tinybase/persisted.ts b/apps/desktop2/src/store/tinybase/persisted.ts index a481d9c35..b526c7b2d 100644 --- a/apps/desktop2/src/store/tinybase/persisted.ts +++ b/apps/desktop2/src/store/tinybase/persisted.ts @@ -38,7 +38,12 @@ import { type InferTinyBaseSchema, jsonObject, type ToStorageType } from "./shar export const STORE_ID = "persisted"; -export const humanSchema = baseHumanSchema.omit({ id: true }).extend({ created_at: z.string() }); +export const humanSchema = baseHumanSchema.omit({ id: true }).extend({ + created_at: z.string(), + job_title: z.preprocess(val => val ?? undefined, z.string().optional()), + linkedin_username: z.preprocess(val => val ?? undefined, z.string().optional()), + is_user: z.preprocess(val => val ?? undefined, z.boolean().optional()), +}); export const eventSchema = baseEventSchema.omit({ id: true }).extend({ created_at: z.string(), @@ -148,6 +153,9 @@ const SCHEMA = { name: { type: "string" }, email: { type: "string" }, org_id: { type: "string" }, + job_title: { type: "string" }, + linkedin_username: { type: "string" }, + is_user: { type: "boolean" }, } satisfies InferTinyBaseSchema, organizations: { user_id: { type: "string" }, @@ -396,6 +404,27 @@ export const StoreComponent = () => { join("events", "event_id").as("event"); select("event", "started_at").as("event_started_at"); }, + ) + .setQueryDefinition( + QUERIES.visibleHumans, + "humans", + ({ select }) => { + select("name"); + select("email"); + select("org_id"); + select("job_title"); + select("linkedin_username"); + select("is_user"); + select("created_at"); + }, + ) + .setQueryDefinition( + QUERIES.visibleOrganizations, + "organizations", + ({ select }) => { + select("name"); + select("created_at"); + }, ), [store2], )!; @@ -463,6 +492,8 @@ export const StoreComponent = () => { export const QUERIES = { eventsWithoutSession: "eventsWithoutSession", sessionsWithMaybeEvent: "sessionsWithMaybeEvent", + visibleOrganizations: "visibleOrganizations", + visibleHumans: "visibleHumans", }; export const METRICS = { diff --git a/apps/desktop2/src/store/zustand/tabs.ts b/apps/desktop2/src/store/zustand/tabs.ts index e1c299bbc..ef81adcad 100644 --- a/apps/desktop2/src/store/zustand/tabs.ts +++ b/apps/desktop2/src/store/zustand/tabs.ts @@ -9,10 +9,10 @@ type State = { }; type Actions = - & TabUpdator + & TabUpdater & TabStateUpdater; -type TabUpdator = { +type TabUpdater = { setTabs: (tabs: Tab[]) => void; openCurrent: (tab: Tab) => void; openNew: (tab: Tab) => void; @@ -22,6 +22,7 @@ type TabUpdator = { }; type TabStateUpdater = { + updateContactsTabState: (tab: Tab, state: Extract["state"]) => void; updateSessionTabState: (tab: Tab, state: Extract["state"]) => void; }; @@ -30,37 +31,42 @@ type Store = State & Actions; export const useTabs = create((set, get, _store) => ({ currentTab: null, tabs: [], - setTabs: (tabs) => set({ tabs, currentTab: tabs.find((t) => t.active) || null }), + setTabs: (tabs) => { + const tabsWithDefaults = tabs.map(t => tabSchema.parse(t)); + set({ tabs: tabsWithDefaults, currentTab: tabsWithDefaults.find((t) => t.active) || null }); + }, openCurrent: (newTab) => { const { tabs } = get(); + const tabWithDefaults = tabSchema.parse(newTab); const existingTabIdx = tabs.findIndex((t) => t.active); if (existingTabIdx === -1) { const nextTabs = tabs - .filter((t) => !isSameTab(t, newTab)) + .filter((t) => !isSameTab(t, tabWithDefaults)) .map((t) => ({ ...t, active: false })) - .concat([{ ...newTab, active: true }]); - set({ tabs: nextTabs, currentTab: newTab }); + .concat([{ ...tabWithDefaults, active: true }]); + set({ tabs: nextTabs, currentTab: tabWithDefaults }); } else { const nextTabs = tabs .map((t, idx) => idx === existingTabIdx - ? { ...newTab, active: true } - : isSameTab(t, newTab) + ? { ...tabWithDefaults, active: true } + : isSameTab(t, tabWithDefaults) ? null : { ...t, active: false } ) .filter((t): t is Tab => t !== null); - set({ tabs: nextTabs, currentTab: newTab }); + set({ tabs: nextTabs, currentTab: tabWithDefaults }); } }, openNew: (tab) => { const { tabs } = get(); + const tabWithDefaults = tabSchema.parse(tab); const nextTabs = tabs - .filter((t) => !isSameTab(t, tab)) + .filter((t) => !isSameTab(t, tabWithDefaults)) .map((t) => ({ ...t, active: false })) - .concat([{ ...tab, active: true }]); - set({ tabs: nextTabs, currentTab: tab }); + .concat([{ ...tabWithDefaults, active: true }]); + set({ tabs: nextTabs, currentTab: tabWithDefaults }); }, select: (tab) => { const { tabs } = get(); @@ -99,6 +105,19 @@ export const useTabs = create((set, get, _store) => ({ : currentTab; set({ tabs: nextTabs, currentTab: nextCurrentTab }); }, + updateContactsTabState: (tab, state) => { + const { tabs, currentTab } = get(); + const nextTabs = tabs.map((t) => + isSameTab(t, tab) && t.type === "contacts" + ? { ...t, state } + : t + ); + + const nextCurrentTab = currentTab && isSameTab(currentTab, tab) && currentTab.type === "contacts" + ? { ...currentTab, state } + : currentTab; + set({ tabs: nextTabs, currentTab: nextCurrentTab }); + }, })); const baseTabSchema = z.object({ @@ -113,6 +132,24 @@ export const tabSchema = z.discriminatedUnion("type", [ editor: z.enum(["raw", "enhanced", "transcript"]).default("raw"), }).default({ editor: "raw" }), }), + baseTabSchema.extend({ + type: z.literal("contacts"), + state: z.object({ + selectedOrganization: z.string().nullable().default(null), + selectedPerson: z.string().nullable().default(null), + editingPerson: z.string().nullable().default(null), + editingOrg: z.string().nullable().default(null), + showNewOrg: z.boolean().default(false), + sortOption: z.enum(["alphabetical", "oldest", "newest"]).default("alphabetical"), + }).default({ + selectedOrganization: null, + selectedPerson: null, + editingPerson: null, + editingOrg: null, + showNewOrg: false, + sortOption: "alphabetical", + }), + }), baseTabSchema.extend({ type: z.literal("events" satisfies typeof TABLES[number]), id: z.string(), @@ -125,14 +162,15 @@ export const tabSchema = z.discriminatedUnion("type", [ type: z.literal("organizations" satisfies typeof TABLES[number]), id: z.string(), }), - baseTabSchema.extend({ - type: z.literal("calendars" satisfies typeof TABLES[number]), - month: z.coerce.date(), - }), baseTabSchema.extend({ type: z.literal("folders" satisfies typeof TABLES[number]), id: z.string().nullable(), }), + + baseTabSchema.extend({ + type: z.literal("calendars"), + month: z.coerce.date(), + }), ]); export type Tab = z.infer; @@ -148,6 +186,7 @@ export const rowIdfromTab = (tab: Tab): string => { case "organizations": return tab.id; case "calendars": + case "contacts": throw new Error("invalid_resource"); case "folders": if (!tab.id) { @@ -169,6 +208,8 @@ export const uniqueIdfromTab = (tab: Tab): string => { return `organizations-${tab.id}`; case "calendars": return `calendars-${tab.month.getFullYear()}-${tab.month.getMonth()}`; + case "contacts": + return `contacts`; case "folders": return `folders-${tab.id ?? "all"}`; } diff --git a/apps/desktop2/vite.config.ts b/apps/desktop2/vite.config.ts index c3a7840b4..81476b129 100644 --- a/apps/desktop2/vite.config.ts +++ b/apps/desktop2/vite.config.ts @@ -24,14 +24,14 @@ export default defineConfig(async () => ({ const tauri: UserConfig = { clearScreen: false, server: { - port: 1420, + port: 1422, strictPort: true, host: host || false, hmr: host ? { protocol: "ws", host, - port: 1421, + port: 1423, } : undefined, watch: { diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index d0cb41017..e762a44a0 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -15,6 +15,9 @@ export const humans = pgTable(TABLE_HUMANS, { name: text("name").notNull(), email: text("email").notNull(), org_id: uuid("org_id").notNull(), + job_title: text("job_title"), + linkedin_username: text("linkedin_username"), + is_user: boolean("is_user"), }); export const TABLE_ORGANIZATIONS = "organizations"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 024ebb361..4fdf1ab0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -382,6 +382,9 @@ importers: '@hypr/db': specifier: workspace:* version: link:../../packages/db + '@hypr/plugin-analytics': + specifier: workspace:* + version: link:../../plugins/analytics '@hypr/plugin-db2': specifier: workspace:* version: link:../../plugins/db2 @@ -406,6 +409,9 @@ importers: '@t3-oss/env-core': specifier: ^0.13.8 version: 0.13.8(typescript@5.8.3)(zod@4.1.12) + '@tanstack/react-query': + specifier: ^5.90.2 + version: 5.90.2(react@19.2.0) '@tanstack/react-router': specifier: ^1.132.47 version: 1.132.47(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -430,6 +436,9 @@ importers: '@tauri-apps/plugin-updater': specifier: ^2.9.0 version: 2.9.0 + '@wavesurfer/react': + specifier: ^1.0.11 + version: 1.0.11(react@19.2.0)(wavesurfer.js@7.11.0) '@xstate/store': specifier: ^3.9.3 version: 3.9.3(react@19.2.0)(solid-js@1.9.7) @@ -457,6 +466,9 @@ importers: tinybase: specifier: ^6.7.0 version: 6.7.0(postgres@3.4.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(ws@8.18.3) + wavesurfer.js: + specifier: ^7.11.0 + version: 7.11.0 zod: specifier: ^4.1.12 version: 4.1.12 @@ -5447,6 +5459,12 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@wavesurfer/react@1.0.11': + resolution: {integrity: sha512-DRpaA3MRTKy4Jby12xvoHASa+w31FZtxaqanXcJjfqNqfamkKi8VJfRnz+Uub9LkpdgoAc3g5SuZF75lEcGgzQ==} + peerDependencies: + react: ^18.2.0 || ^19.0.0 + wavesurfer.js: '>=7.7.14' + '@wdio/cli@8.46.0': resolution: {integrity: sha512-ZT7z4buheFtoXmL8/EPyrspXSwrVRKUI27GLY34hGOjHAhry4dTJ1ODC5ARs0PbuM//yJcJb8q18wa+2xGqf3w==} engines: {node: ^16.13 || >=18} @@ -11849,6 +11867,9 @@ packages: resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} engines: {node: '>=10.13.0'} + wavesurfer.js@7.11.0: + resolution: {integrity: sha512-LOGdIBIKv/roYuQYClhoqhwbIdQL1GfobLnS2vx0heoLD9lu57OUHWE2DIsCNXBvCsmmbkUvJq9W8bPLPbikGw==} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -17129,6 +17150,11 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@wavesurfer/react@1.0.11(react@19.2.0)(wavesurfer.js@7.11.0)': + dependencies: + react: 19.2.0 + wavesurfer.js: 7.11.0 + '@wdio/cli@8.46.0': dependencies: '@types/node': 22.18.8 @@ -25086,6 +25112,8 @@ snapshots: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 + wavesurfer.js@7.11.0: {} + wcwidth@1.0.1: dependencies: defaults: 1.0.4