csv-export

SKILL.md

CSV Export Skill

Overview

This skill implements data export functionality for RFPs, evaluations, and pursuit pipelines in multiple formats.

Export Types

RFP List Export

interface RfpExportRow {
  id: string;
  externalId: string;
  source: string;
  title: string;
  description: string;
  location: string;
  category: string;
  postedDate: string;
  expiryDate: string;
  daysRemaining: number;
  url: string;
  // Evaluation
  score: number | null;
  isFit: boolean | null;
  eligibilityStatus: string | null;
  // Pursuit
  pursuitStatus: string | null;
  decision: string | null;
}

Evaluation Export

interface EvaluationExportRow {
  rfpId: string;
  rfpTitle: string;
  evaluatedAt: string;
  overallScore: number;
  isFit: boolean;
  eligibilityStatus: string;
  // Per-criterion (dynamic columns)
  [criterionName_score: string]: number;
  [criterionName_met: string]: boolean;
  [criterionName_keywords: string]: string;
  reasoning: string;
}

Pursuit Pipeline Export

interface PursuitExportRow {
  rfpId: string;
  rfpTitle: string;
  source: string;
  deadline: string;
  daysRemaining: number;
  status: string;
  decision: string;
  decisionBy: string;
  decisionDate: string;
  score: number;
  teamMembers: string;
  notes: string;
}

CSV Generation

// services/csvExport.ts

type ExportableValue = string | number | boolean | null | undefined;
type ExportRow = Record<string, ExportableValue>;

export function generateCsv(
  data: ExportRow[],
  options?: {
    headers?: string[];
    delimiter?: string;
    includeHeaders?: boolean;
  }
): string {
  if (data.length === 0) return "";

  const delimiter = options?.delimiter ?? ",";
  const includeHeaders = options?.includeHeaders ?? true;
  const headers = options?.headers ?? Object.keys(data[0]);

  const rows: string[] = [];

  // Header row
  if (includeHeaders) {
    rows.push(headers.map(escapeForCsv).join(delimiter));
  }

  // Data rows
  for (const row of data) {
    const values = headers.map((header) =>
      escapeForCsv(formatValue(row[header]))
    );
    rows.push(values.join(delimiter));
  }

  return rows.join("\n");
}

function escapeForCsv(value: string): string {
  if (value.includes(",") || value.includes('"') || value.includes("\n")) {
    return `"${value.replace(/"/g, '""')}"`;
  }
  return value;
}

function formatValue(value: ExportableValue): string {
  if (value === null || value === undefined) return "";
  if (typeof value === "boolean") return value ? "Yes" : "No";
  if (typeof value === "number") return value.toString();
  return String(value);
}

export function downloadCsv(csv: string, filename: string): void {
  const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");
  link.href = url;
  link.download = filename;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
  URL.revokeObjectURL(url);
}

Convex Export Queries

// convex/exports.ts
import { query } from "./_generated/server";
import { v } from "convex/values";

export const exportRfps = query({
  args: {
    source: v.optional(v.string()),
    showOnlyFit: v.optional(v.boolean()),
    limit: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    let q = ctx.db.query("rfps");

    if (args.source) {
      q = q.withIndex("by_source", (q) => q.eq("source", args.source));
    }

    const rfps = await q.take(args.limit ?? 500);

    // Join with evaluations and pursuits
    const exportData = await Promise.all(
      rfps.map(async (rfp) => {
        const evaluation = await ctx.db
          .query("evaluations")
          .withIndex("by_rfp", (q) => q.eq("rfpId", rfp._id))
          .order("desc")
          .first();

        const pursuit = await ctx.db
          .query("pursuits")
          .withIndex("by_rfp", (q) => q.eq("rfpId", rfp._id))
          .first();

        // Filter by fit if requested
        if (args.showOnlyFit && !evaluation?.isFit) {
          return null;
        }

        return {
          id: rfp._id,
          externalId: rfp.externalId,
          source: rfp.source,
          title: rfp.title,
          description: truncate(rfp.description, 500),
          location: rfp.location,
          category: rfp.category,
          postedDate: formatDate(rfp.postedDate),
          expiryDate: formatDate(rfp.expiryDate),
          daysRemaining: calculateDaysRemaining(rfp.expiryDate),
          url: rfp.url,
          score: evaluation?.score ?? null,
          isFit: evaluation?.isFit ?? null,
          eligibilityStatus: evaluation?.eligibility?.status ?? null,
          pursuitStatus: pursuit?.status ?? null,
          decision: pursuit?.decision ?? null,
        };
      })
    );

    return exportData.filter(Boolean);
  },
});

export const exportEvaluations = query({
  args: {
    startDate: v.optional(v.number()),
    endDate: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    let evaluations = await ctx.db.query("evaluations").collect();

    // Date filter
    if (args.startDate || args.endDate) {
      evaluations = evaluations.filter((e) => {
        if (args.startDate && e.evaluatedAt < args.startDate) return false;
        if (args.endDate && e.evaluatedAt > args.endDate) return false;
        return true;
      });
    }

    return Promise.all(
      evaluations.map(async (eval_) => {
        const rfp = await ctx.db.get(eval_.rfpId);

        // Flatten criteria results
        const criteriaData: Record<string, any> = {};
        for (const result of eval_.criteriaResults) {
          const key = result.criterionName.toLowerCase().replace(/\s+/g, "_");
          criteriaData[`${key}_score`] = result.score;
          criteriaData[`${key}_met`] = result.met;
          criteriaData[`${key}_keywords`] = result.matchedKeywords.join("; ");
        }

        return {
          rfpId: eval_.rfpId,
          rfpTitle: rfp?.title ?? "Unknown",
          evaluatedAt: formatDateTime(eval_.evaluatedAt),
          overallScore: eval_.score,
          isFit: eval_.isFit,
          eligibilityStatus: eval_.eligibility.status,
          ...criteriaData,
          reasoning: eval_.reasoning ?? "",
        };
      })
    );
  },
});

export const exportPursuits = query({
  args: {
    status: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    let q = ctx.db.query("pursuits");

    if (args.status) {
      q = q.filter((q) => q.eq(q.field("status"), args.status));
    }

    const pursuits = await q.collect();

    return Promise.all(
      pursuits.map(async (pursuit) => {
        const rfp = await ctx.db.get(pursuit.rfpId);
        const evaluation = await ctx.db
          .query("evaluations")
          .withIndex("by_rfp", (q) => q.eq("rfpId", pursuit.rfpId))
          .first();

        return {
          rfpId: pursuit.rfpId,
          rfpTitle: rfp?.title ?? "Unknown",
          source: rfp?.source ?? "Unknown",
          deadline: rfp ? formatDate(rfp.expiryDate) : "",
          daysRemaining: rfp ? calculateDaysRemaining(rfp.expiryDate) : null,
          status: pursuit.status,
          decision: pursuit.decision ?? "",
          decisionBy: pursuit.decisionBy ?? "",
          decisionDate: pursuit.decisionAt ? formatDate(pursuit.decisionAt) : "",
          score: evaluation?.score ?? null,
          teamMembers: pursuit.teamMembers?.join("; ") ?? "",
          notes: pursuit.notes ?? "",
        };
      })
    );
  },
});

// Helpers
function formatDate(timestamp: number): string {
  return new Date(timestamp).toISOString().split("T")[0];
}

function formatDateTime(timestamp: number): string {
  return new Date(timestamp).toISOString();
}

function calculateDaysRemaining(expiryDate: number): number {
  return Math.ceil((expiryDate - Date.now()) / (1000 * 60 * 60 * 24));
}

function truncate(text: string, maxLength: number): string {
  if (text.length <= maxLength) return text;
  return text.substring(0, maxLength - 3) + "...";
}

React Components

// components/ExportButton.tsx
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
import { generateCsv, downloadCsv } from "../services/csvExport";

interface ExportButtonProps {
  exportType: "rfps" | "evaluations" | "pursuits";
  filters?: Record<string, any>;
  filename?: string;
}

export function ExportButton({ exportType, filters, filename }: ExportButtonProps) {
  const [isExporting, setIsExporting] = useState(false);

  // Get query based on type
  const queryFn =
    exportType === "rfps"
      ? api.exports.exportRfps
      : exportType === "evaluations"
        ? api.exports.exportEvaluations
        : api.exports.exportPursuits;

  const data = useQuery(queryFn, filters ?? {});

  const handleExport = () => {
    if (!data) return;

    setIsExporting(true);
    try {
      const csv = generateCsv(data);
      const defaultFilename = `${exportType}-${formatDateForFilename(new Date())}.csv`;
      downloadCsv(csv, filename ?? defaultFilename);
    } finally {
      setIsExporting(false);
    }
  };

  return (
    <button
      onClick={handleExport}
      disabled={isExporting || !data}
      className="flex items-center gap-2 px-4 py-2 bg-secondary text-secondary-foreground rounded hover:bg-secondary/80 disabled:opacity-50"
    >
      <DownloadIcon className="w-4 h-4" />
      {isExporting ? "Exporting..." : "Export CSV"}
    </button>
  );
}

Export Panel

// components/ExportPanel.tsx
export function ExportPanel() {
  const [exportType, setExportType] = useState<"rfps" | "evaluations" | "pursuits">("rfps");
  const [filters, setFilters] = useState({
    source: "",
    showOnlyFit: false,
    status: "",
  });

  return (
    <div className="p-6 bg-card rounded-lg space-y-4">
      <h2 className="text-xl font-semibold">Export Data</h2>

      {/* Export Type */}
      <div>
        <label className="block text-sm text-muted-foreground mb-2">
          Export Type
        </label>
        <select
          value={exportType}
          onChange={(e) => setExportType(e.target.value as any)}
          className="w-full p-2 bg-background border rounded"
        >
          <option value="rfps">RFP List</option>
          <option value="evaluations">Evaluation Details</option>
          <option value="pursuits">Pursuit Pipeline</option>
        </select>
      </div>

      {/* Filters */}
      {exportType === "rfps" && (
        <div className="space-y-2">
          <select
            value={filters.source}
            onChange={(e) => setFilters({ ...filters, source: e.target.value })}
            className="w-full p-2 bg-background border rounded"
          >
            <option value="">All Sources</option>
            <option value="sam.gov">SAM.gov</option>
            <option value="emma">Maryland eMMA</option>
            <option value="rfpmart">RFPMart</option>
          </select>

          <label className="flex items-center gap-2">
            <input
              type="checkbox"
              checked={filters.showOnlyFit}
              onChange={(e) =>
                setFilters({ ...filters, showOnlyFit: e.target.checked })
              }
            />
            <span className="text-sm">Show only fit opportunities</span>
          </label>
        </div>
      )}

      <ExportButton exportType={exportType} filters={filters} />

      <p className="text-xs text-muted-foreground">
        Exports include all visible columns. Dates are in ISO 8601 format.
      </p>
    </div>
  );
}

JSON Export Alternative

export function downloadJson(data: any, filename: string): void {
  const json = JSON.stringify(data, null, 2);
  const blob = new Blob([json], { type: "application/json" });
  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");
  link.href = url;
  link.download = filename;
  link.click();
  URL.revokeObjectURL(url);
}

Selected Items Export

// components/SelectionExport.tsx
export function SelectionExport({
  selectedIds,
}: {
  selectedIds: Id<"rfps">[];
}) {
  const exportSelected = async () => {
    const data = await convex.query(api.exports.exportRfps, {
      rfpIds: selectedIds,
    });
    const csv = generateCsv(data);
    downloadCsv(csv, `selected-rfps-${formatDate(new Date())}.csv`);
  };

  return (
    <button
      onClick={exportSelected}
      disabled={selectedIds.length === 0}
      className="px-4 py-2 bg-primary text-primary-foreground rounded disabled:opacity-50"
    >
      Export {selectedIds.length} Selected
    </button>
  );
}
Weekly Installs
4
First Seen
Feb 26, 2026
Installed on
github-copilot4
codex4
kimi-cli4
amp4
gemini-cli4
openclaw4