import { useT } from "@agent-native/core/client/i18n"; import { IconCheck, IconClock, IconExternalLink, IconNotes, IconVideo, } from "@tabler/icons-react"; /** * — Granola-style meeting tile. * * Renders title, time, attendee stack, status pills (Live / Transcript ready * / Notes ready), and a 1-2 line summary preview. Hover lifts the card. */ import { NavLink } from "react-router"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { cn } from "@/lib/utils"; import { AttendeeStack, type AttendeeStackParticipant } from "./attendee-stack"; export interface MeetingCardData { id: string; title: string; scheduledStart: string; scheduledEnd?: string | null; actualStart?: string | null; actualEnd?: string | null; recordingId?: string | null; transcriptStatus?: | "pending" | "ready" | "failed" | "in_progress" | string | null; summaryPreview?: string | null; summaryMd?: string | null; participants?: AttendeeStackParticipant[]; } type Translate = (key: string, params?: Record) => string; function formatTime(iso?: string | null): string { if (!iso) return ""; try { return new Date(iso).toLocaleTimeString([], { hour: "numeric", minute: "2-digit", }); } catch { return ""; } } function buildPreview(m: MeetingCardData): string | null { if (m.summaryPreview && m.summaryPreview.trim()) return m.summaryPreview; if (m.summaryMd && m.summaryMd.trim()) { // Strip simple markdown markers and collapse whitespace for the preview. const plain = m.summaryMd .replace(/[#*`>_~-]+/g, " ") .replace(/\s+/g, " ") .trim(); return plain.slice(0, 220); } return null; } export function MeetingCard({ meeting }: { meeting: MeetingCardData }) { const t = useT(); const isLive = !!( (meeting.actualStart && !meeting.actualEnd) || meeting.transcriptStatus === "in_progress" ); const transcriptReady = meeting.transcriptStatus === "ready"; const hasNotes = !!meeting.summaryMd; const preview = buildPreview(meeting); const now = Date.now(); const scheduledEndMs = Date.parse( meeting.scheduledEnd ?? meeting.scheduledStart, ); const meetingHasEnded = !!meeting.actualEnd || (!Number.isNaN(scheduledEndMs) && scheduledEndMs < now); const shouldShowMissingSummary = !preview && (meetingHasEnded || !!meeting.recordingId || transcriptReady || isLive); return (

{meeting.title || t("meetingDetail.untitledMeeting")}

{isLive ? ( {t("meetingCard.live")} ) : transcriptReady ? ( {t("meetingCard.transcript")} ) : null} {hasNotes && !isLive && ( )}
{formatTime(meeting.scheduledStart)} {meeting.scheduledEnd && ( – {formatTime(meeting.scheduledEnd)} )}
{preview ? (

{preview}

) : shouldShowMissingSummary ? (

{t("meetingCard.noSummary")}

) : null}
{meeting.recordingId && ( {t("meetingCard.transcriptSource")} )}
); } export interface UpcomingMeetingCardData { id: string; title: string; scheduledStart: string; scheduledEnd?: string | null; actualStart?: string | null; actualEnd?: string | null; joinUrl?: string | null; platform?: string | null; participants?: AttendeeStackParticipant[]; } /** Human "starts in 5 min" / "now" / "in 2 hrs" label for an upcoming card. */ function relativeStartLabel( iso: string, t: Translate, ): { text: string; soon: boolean } { const start = Date.parse(iso); if (Number.isNaN(start)) return { text: "", soon: false }; const diffMin = Math.round((start - Date.now()) / 60000); if (diffMin <= 0 && diffMin > -120) return { text: t("meetingCard.now"), soon: true }; if (diffMin <= 0) return { text: t("meetingCard.started"), soon: false }; if (diffMin < 60) return { text: t("meetingCard.inMinutes", { count: diffMin }), soon: diffMin <= 5, }; const hrs = Math.round(diffMin / 60); return { text: t("meetingCard.inHours", { count: hrs }), soon: false }; } /** * — a not-yet-recorded calendar event (Granola-style). * Recording is a native Clips desktop-app gesture, so the card just opens the * meeting's notes ("Open notes") and links out to the call ("Join"). Buttons are * NavLink/ siblings (never nested anchors) so the markup stays valid. */ export function UpcomingMeetingCard({ meeting, }: { meeting: UpcomingMeetingCardData; }) { const t = useT(); const isLive = !!(meeting.actualStart && !meeting.actualEnd); const { text: whenText, soon } = relativeStartLabel( meeting.scheduledStart, t, ); const joinable = soon || isLive; return (

{meeting.title || t("meetingDetail.untitledMeeting")}

{isLive ? ( {t("meetingCard.live")} ) : ( {whenText} )}
{formatTime(meeting.scheduledStart)} {meeting.scheduledEnd && ( – {formatTime(meeting.scheduledEnd)} )}

{soon || isLive ? t("meetingCard.startFromDesktopNow") : t("meetingCard.startFromDesktopLater")}

{meeting.joinUrl && ( e.stopPropagation()} > )}
); } export function MeetingCardSkeleton() { return (
); }