MeetingsDebuggerTab.tsx20.1 KBView on GitHub
'use client';

import {
  Check,
  ChevronDown,
  ChevronRight,
  Copy,
  ExternalLink,
  FileText,
  Loader2,
  Search,
  Video,
  Zap,
  HelpCircle,
  Star,
} from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { useLocation, useParams } from 'react-router';
import { useTRPC } from '@/providers/query-provider';
import { useQuery } from '@tanstack/react-query';
import React, { useMemo, useState } from 'react';
import { cn } from '@/styles/stylingUtils';
import { format } from 'date-fns';
import { JsonTreeView } from './JsonTreeView';

type CallFlagEntry = {
  type: 'important' | 'risk' | 'share';
  startMs: number;
  endMs: number;
  createdAt: number;
};

interface RecallTranscriptWord {
  text: string;
  start_time: number;
  end_time: number;
}

interface RecallTranscriptSegment {
  speaker: string;
  words: RecallTranscriptWord[];
}

type SessionRow = {
  sdkUploadId: string;
  userId: string;
  conversationId: string | null;
  title: string | null;
  notes: string | null;
  callFlags: CallFlagEntry[] | null;
  createdAt: Date;
  updatedAt: Date;
};

const FLAG_META = {
  important: { icon: Zap, color: '#eab308', label: 'Important', bg: '#fefce8' },
  risk: { icon: HelpCircle, color: '#3b82f6', label: 'Risk', bg: '#eff6ff' },
  share: { icon: Star, color: '#a855f7', label: 'Share', bg: '#faf5ff' },
} as const;

function formatMs(ms: number): string {
  const totalSeconds = Math.floor(ms / 1000);
  const minutes = Math.floor(totalSeconds / 60);
  const seconds = totalSeconds % 60;
  return `${minutes}:${String(seconds).padStart(2, '0')}`;
}

function FlagTimeline({ flags }: { flags: CallFlagEntry[] }) {
  if (flags.length === 0) return null;

  const totalMs = Math.max(...flags.map((f) => f.endMs), 1);

  // Deduplicate by startMs+type to avoid stacked dots
  const unique = flags.filter(
    (f, i, arr) => arr.findIndex((x) => x.startMs === f.startMs && x.type === f.type) === i,
  );

  return (
    <div className="rounded bg-white p-2 dark:bg-gray-800">
      <span className="text-gray-500 dark:text-gray-400">
        Flags ({flags.length} total, {unique.length} unique positions)
      </span>
      <div className="relative mt-2 h-5">
        {/* Track */}
        <div className="absolute left-0 right-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-gray-200 dark:bg-gray-700" />
        {/* Markers */}
        {unique.map((f, i) => {
          const meta = FLAG_META[f.type];
          const pct = (f.startMs / totalMs) * 100;
          return (
            <div
              key=[redacted]
              title={`${meta.label} @ ${formatMs(f.startMs)}`}
              className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-default rounded-full border border-white dark:border-gray-800"
              style={{ left: `${pct}%`, backgroundColor: meta.color }}
            />
          );
        })}
      </div>
      {/* Legend */}
      <div className="mt-2 flex items-center gap-3">
        {(['important', 'risk', 'share'] as const)
          .filter((t) => flags.some((f) => f.type === t))
          .map((t) => {
            const meta = FLAG_META[t];
            const count = flags.filter((f) => f.type === t).length;
            return (
              <span key={t} className="flex items-center gap-1 text-[10px]">
                <span
                  className="inline-block h-2 w-2 rounded-full"
                  style={{ backgroundColor: meta.color }}
                />
                {meta.label} ×{count}
              </span>
            );
          })}
        <span className="ml-auto text-[10px] text-gray-400">{formatMs(totalMs)} total</span>
      </div>
    </div>
  );
}

interface MeetingsTabProps {
  onCopy: (text: string, id: string) => void;
  copiedId: string | null;
}

export const MeetingsDebuggerTab: React.FC<MeetingsTabProps> = ({ onCopy, copiedId }) => {
  const trpc = useTRPC();
  const location = useLocation();
  const params = useParams<{ sdkUploadId?: string }>();

  // Detect if we're viewing a specific meeting
  const activeSdkUploadId = params.sdkUploadId ?? null;

  const { data: sessionsRaw, isLoading } = useQuery(trpc.recall.listCallSessions.queryOptions());
  const sessions = (sessionsRaw as unknown as SessionRow[]) ?? [];

  const { data: activeSessionRaw, isLoading: isLoadingActive } = useQuery({
    ...trpc.recall.getCallSession.queryOptions({ sdkUploadId: activeSdkUploadId ?? '' }),
    enabled: !!activeSdkUploadId,
  });
  const activeSession = activeSessionRaw as unknown as SessionRow | null | undefined;

  const { data: activeRecordingRaw, isLoading: isLoadingRecording } = useQuery({
    ...trpc.recall.getRecording.queryOptions({ sdkUploadId: activeSdkUploadId ?? '' }),
    enabled: !!activeSdkUploadId,
  });

  const [searchQuery, setSearchQuery] = useState('');
  const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());

  const filteredSessions = useMemo(() => {
    if (!searchQuery) return sessions;
    const q = searchQuery.toLowerCase();
    return sessions.filter(
      (s) =>
        s.sdkUploadId.toLowerCase().includes(q) ||
        (s.title ?? '').toLowerCase().includes(q) ||
        (s.conversationId ?? '').toLowerCase().includes(q),
    );
  }, [sessions, searchQuery]);

  const toggleExpanded = (id: string) => {
    setExpandedIds((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };

  const safeStringify = (obj: unknown) => {
    try {
      return JSON.stringify(obj, null, 2);
    } catch {
      return '[unserializable]';
    }
  };

  return (
    <div className="flex h-full flex-col">
      {/* ── Active meeting panel ── */}
      {activeSdkUploadId && (
        <div className="border-b border-gray-200 bg-rose-50/50 p-3 dark:border-gray-700 dark:bg-rose-950/20">
          <div className="mb-2 flex items-center justify-between">
            <div className="flex items-center gap-2">
              <Video className="h-4 w-4 text-rose-600 dark:text-rose-400" />
              <span className="font-medium text-rose-900 dark:text-rose-100">Current Meeting</span>
              {(isLoadingActive || isLoadingRecording) && (
                <Loader2 className="h-3 w-3 animate-spin text-rose-400" />
              )}
            </div>
            <div className="flex items-center gap-1">
              <button
                onClick={() =>
                  onCopy(
                    safeStringify({
                      route: location.pathname,
                      sdkUploadId: activeSdkUploadId,
                      session: activeSession,
                      recording: activeRecordingRaw,
                    }),
                    'activeMeetingFull',
                  )
                }
                className="rounded p-0.5 hover:bg-rose-200 dark:hover:bg-rose-800"
                title="Copy all"
              >
                {copiedId === 'activeMeetingFull' ? (
                  <Check className="h-3 w-3 text-green-600" />
                ) : (
                  <Copy className="h-3 w-3" />
                )}
              </button>
            </div>
          </div>

          <div className="space-y-1.5 text-xs">
            {/* sdkUploadId */}
            <div className="flex items-center justify-between rounded bg-white p-2 dark:bg-gray-800">
              <div>
                <span className="text-gray-500 dark:text-gray-400">sdkUploadId</span>
                <div className="font-mono">{activeSdkUploadId}</div>
              </div>
              <button
                onClick={() => onCopy(activeSdkUploadId, 'activeSdkUploadId')}
                className="rounded p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700"
              >
                {copiedId === 'activeSdkUploadId' ? (
                  <Check className="h-3 w-3 text-green-600" />
                ) : (
                  <Copy className="h-3 w-3" />
                )}
              </button>
            </div>

            {/* title */}
            {activeSession?.title && (
              <div className="rounded bg-white p-2 dark:bg-gray-800">
                <span className="text-gray-500 dark:text-gray-400">Title</span>
                <div className="font-medium">{activeSession.title}</div>
              </div>
            )}

            {/* conversationId */}
            {activeSession?.conversationId && (
              <div className="flex items-center justify-between rounded bg-white p-2 dark:bg-gray-800">
                <div>
                  <span className="text-gray-500 dark:text-gray-400">conversationId</span>
                  <div className="font-mono">{activeSession.conversationId}</div>
                </div>
                <button
                  onClick={() => onCopy(activeSession.conversationId!, 'activeConversationId')}
                  className="rounded p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700"
                >
                  {copiedId === 'activeConversationId' ? (
                    <Check className="h-3 w-3 text-green-600" />
                  ) : (
                    <Copy className="h-3 w-3" />
                  )}
                </button>
              </div>
            )}

            {/* recording status */}
            {activeRecordingRaw && (
              <div className="rounded bg-white p-2 dark:bg-gray-800">
                <span className="text-gray-500 dark:text-gray-400">Recording status</span>
                <div className="flex items-center gap-2">
                  <span
                    className={cn(
                      'rounded px-1.5 py-0.5 font-mono',
                      ['complete', 'done'].includes(
                        (activeRecordingRaw as { status?: string }).status ?? '',
                      )
                        ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
                        : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300',
                    )}
                  >
                    {(activeRecordingRaw as { status?: string }).status ?? 'unknown'}
                  </span>
                  <span
                    className={cn(
                      'rounded px-1.5 py-0.5 font-mono',
                      (activeRecordingRaw as { video_mixed_mp4?: { status?: string } })
                        ?.video_mixed_mp4?.status === 'done'
                        ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
                        : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
                    )}
                  >
                    video:{' '}
                    {(activeRecordingRaw as { video_mixed_mp4?: { status?: string } })
                      ?.video_mixed_mp4?.status ?? 'none'}
                  </span>
                </div>
              </div>
            )}

            {/* call flags timeline */}
            {activeSession?.callFlags && activeSession.callFlags.length > 0 && (
              <FlagTimeline flags={activeSession.callFlags as CallFlagEntry[]} />
            )}

            {/* transcript */}
            {(() => {
              const transcript = (
                activeRecordingRaw as { transcript?: RecallTranscriptSegment[] } | undefined
              )?.transcript;
              if (!transcript || transcript.length === 0) return null;
              return (
                <div className="rounded bg-white p-2 dark:bg-gray-800">
                  <div className="flex items-center justify-between">
                    <span className="text-gray-500 dark:text-gray-400">
                      Transcript ({transcript.length} segments)
                    </span>
                    <button
                      onClick={() =>
                        onCopy(
                          transcript
                            .map(
                              (seg) => `[${seg.speaker}] ${seg.words.map((w) => w.text).join(' ')}`,
                            )
                            .join('\n'),
                          'activeTranscript',
                        )
                      }
                      className="rounded p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700"
                    >
                      {copiedId === 'activeTranscript' ? (
                        <Check className="h-3 w-3 text-green-600" />
                      ) : (
                        <Copy className="h-3 w-3" />
                      )}
                    </button>
                  </div>
                  <div className="mt-1.5 max-h-40 space-y-1.5 overflow-y-auto">
                    {transcript.map((seg, i) => {
                      const startMs = (seg.words[0]?.start_time ?? 0) * 1000;
                      return (
                        <div key=[redacted] className="flex gap-2">
                          <span className="shrink-0 font-mono text-gray-400">
                            {formatMs(startMs)}
                          </span>
                          <span className="flex-1">
                            <span className="font-medium text-gray-600 dark:text-gray-300">
                              {seg.speaker}:
                            </span>{' '}
                            {seg.words.map((w) => w.text).join(' ')}
                          </span>
                        </div>
                      );
                    })}
                  </div>
                </div>
              );
            })()}

            {/* created at */}
            {activeSession?.createdAt && (
              <div className="rounded bg-white p-2 dark:bg-gray-800">
                <span className="text-gray-500 dark:text-gray-400">Created</span>
                <div className="font-mono">
                  {format(new Date(activeSession.createdAt), 'MMM d, yyyy · h:mm a')}
                </div>
              </div>
            )}
          </div>
        </div>
      )}

      {!activeSdkUploadId && (
        <div className="border-b border-gray-200 bg-gray-50 p-2.5 dark:border-gray-700 dark:bg-gray-900">
          <div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
            <ExternalLink className="h-3 w-3" />
            Open a meeting from /meetings to see its details here
          </div>
        </div>
      )}

      {/* ── Search ── */}
      <div className="border-b border-gray-200 p-2 dark:border-gray-700">
        <div className="relative">
          <Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-gray-400" />
          <input
            type="text"
            placeholder="Search sessions…"
            value={searchQuery}
            onChange={(e) => setSearchQuery(e.target.value)}
            className="w-full rounded border border-gray-300 bg-white py-1 pl-7 pr-2 text-xs placeholder:text-gray-400 focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-white"
          />
        </div>
      </div>

      {/* ── Session list ── */}
      <div className="flex-1 overflow-y-auto p-2">
        {isLoading ? (
          <div className="flex items-center justify-center py-8">
            <Loader2 className="h-5 w-5 animate-spin text-gray-400" />
          </div>
        ) : filteredSessions.length === 0 ? (
          <div className="flex flex-col items-center gap-2 py-8 text-center">
            <FileText className="h-6 w-6 text-gray-300" />
            <p className="text-xs text-gray-500">
              {searchQuery ? 'No sessions match' : 'No recorded sessions'}
            </p>
          </div>
        ) : (
          <div className="space-y-2">
            {filteredSessions.map((session) => {
              const isExpanded = expandedIds.has(session.sdkUploadId);
              const isActive = session.sdkUploadId === activeSdkUploadId;
              const flags = (session.callFlags ?? []) as CallFlagEntry[];

              return (
                <div
                  key=[redacted]
                  className={cn(
                    'rounded-lg border bg-gray-50 dark:bg-gray-950',
                    isActive
                      ? 'border-rose-400 dark:border-rose-600'
                      : 'border-gray-200 dark:border-gray-700',
                  )}
                >
                  <div
                    className={cn(
                      'flex cursor-pointer items-center justify-between p-2 hover:bg-gray-100 dark:hover:bg-gray-900/80',
                      isExpanded ? 'rounded-t-lg' : 'rounded-lg',
                    )}
                    onClick={() => toggleExpanded(session.sdkUploadId)}
                  >
                    <div className="flex min-w-0 flex-1 flex-col gap-0.5">
                      <div className="flex items-center gap-1.5">
                        {isActive && (
                          <span className="rounded bg-rose-500 px-1 py-0.5 text-xs font-bold text-white">
                            OPEN
                          </span>
                        )}
                        <span className="truncate text-xs font-medium">
                          {session.title ?? 'Untitled Meeting'}
                        </span>
                      </div>
                      <div className="flex items-center gap-2 font-mono text-xs text-gray-500 dark:text-gray-400">
                        <span className="truncate">{session.sdkUploadId}</span>
                      </div>
                      <div className="flex items-center gap-2 text-xs text-gray-400">
                        <span>{format(new Date(session.createdAt), 'MMM d · h:mm a')}</span>
                        {session.conversationId && (
                          <span className="rounded bg-purple-100 px-1 py-0.5 font-mono text-purple-700 dark:bg-purple-900 dark:text-purple-300">
                            conv linked
                          </span>
                        )}
                        {flags.length > 0 && (
                          <span className="flex items-center gap-0.5">
                            <Zap className="h-3 w-3 text-yellow-500" />
                            {flags.length}
                          </span>
                        )}
                      </div>
                    </div>
                    <div className="flex shrink-0 items-center gap-1">
                      <button
                        onClick={(e) => {
                          e.stopPropagation();
                          onCopy(safeStringify(session), `session-${session.sdkUploadId}`);
                        }}
                        className="rounded p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700"
                      >
                        {copiedId === `session-${session.sdkUploadId}` ? (
                          <Check className="h-3 w-3 text-green-600" />
                        ) : (
                          <Copy className="h-3 w-3" />
                        )}
                      </button>
                      {isExpanded ? (
                        <ChevronDown className="h-3 w-3" />
                      ) : (
                        <ChevronRight className="h-3 w-3" />
                      )}
                    </div>
                  </div>

                  <AnimatePresence>
                    {isExpanded && (
                      <motion.div
                        initial={{ height: 0, opacity: 0 }}
                        animate={{ height: 'auto', opacity: 1 }}
                        exit={{ height: 0, opacity: 0 }}
                        transition={{ duration: 0.15 }}
                        className="overflow-hidden"
                      >
                        <div className="rounded-b-lg p-2 pt-0" onClick={(e) => e.stopPropagation()}>
                          <JsonTreeView data={session} onCopy={onCopy} copiedId={copiedId} defaultExpandDepth={2} className="max-h-64" />
                        </div>
                      </motion.div>
                    )}
                  </AnimatePresence>
                </div>
              );
            })}
          </div>
        )}
      </div>

      {/* Footer */}
      <div className="border-t border-gray-200 p-2 text-center text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400">
        {searchQuery ? `${filteredSessions.length} of ${sessions.length}` : `${sessions.length}`}{' '}
        sessions
      </div>
    </div>
  );
};