Agent skill

csv-export

Export RFP data, evaluations, and pursuits in CSV and other formats. Use when implementing data export features, building reports, or extracting data for analysis.

Stars 163
Forks 31

Install this agent skill to your Project

npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/data/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

typescript
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

typescript
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

typescript
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

typescript
// 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

typescript
// 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

tsx
// 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

tsx
// 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

typescript
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

tsx
// 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>
  );
}

Didn't find tool you were looking for?

Be as detailed as possible for better results