DEV Community

Cover image for Building an AI-Powered Tender Tools Suite: Calculators, Planners, and Intelligence
mobius-crypt
mobius-crypt

Posted on

Building an AI-Powered Tender Tools Suite: Calculators, Planners, and Intelligence

How we built 10+ specialized tools for government tender preparation including CIDB grading, B-BBEE calculators, JV analysis, and AI proposal generation

Building an AI-Powered Tender Tools Suite: From Calculators to AI Proposal Generation

After building our professional services platform, we realized businesses needed more than just compliance helpβ€”they needed strategic tools to make better bidding decisions.

This is the technical story of how we built a comprehensive toolkit including:

  • πŸ“Š CIDB Grade Calculator
  • 🎯 B-BBEE Points Estimator
  • πŸ“… Tender Preparation Planner
  • 🀝 Joint Venture Suite with fronting risk detection
  • πŸ€– AI Proposal Generator
  • πŸ“ˆ Tender Value Estimator with historical data analysis
  • πŸ—ΊοΈ Provincial Tender Heatmap

Let's dive into the architecture, algorithms, and lessons learned.

The Philosophy: Tools, Not Black Boxes

We wanted users to understand their calculations, not just trust them. This meant:

interface ToolOutput<T> {
  result: T;
  confidence: number;
  reasoning: string[];
  sources?: string[];
  suggestions?: string[];
}
Enter fullscreen mode Exit fullscreen mode

Every tool returns not just a number, but why that number and how to improve it.

Architecture: Shared Tool Infrastructure

Rather than building each tool from scratch, we created a shared framework:

// lib/tools/base-tool.ts
export abstract class BaseTool<TInput, TOutput> {
  abstract name: string;
  abstract description: string;

  // Validation
  abstract validateInput(input: TInput): ValidationResult;

  // Calculation
  abstract calculate(input: TInput): Promise<TOutput>;

  // Explanation
  abstract explain(input: TInput, output: TOutput): string[];

  // Optional: Save to profile
  async saveToProfile?(userId: string, result: TOutput): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

This gave us:

  • βœ… Consistent input validation
  • βœ… Built-in error handling
  • βœ… Automatic explanation generation
  • βœ… Optional profile integration

Tool #1: CIDB Grade Calculator

The Construction Industry Development Board (CIDB) uses financial and project history to determine contractor grades (1-9). Higher grades = bigger contract values.

The Algorithm

// lib/tools/cidb-calculator.ts
interface CidbInput {
  classOfWorks: CidbClass; // e.g., "GB" (General Building)
  bestTurnover: number;
  availableCapital: number;
  largestContract: number;
}

interface CidbOutput {
  grade: number;
  maxContractValue: number;
  nextGrade?: {
    grade: number;
    shortfall: {
      turnover: number;
      capital: number;
      contract: number;
    };
  };
  reason: string;
}

export function calculateCidbGrade(input: CidbInput): CidbOutput {
  const { bestTurnover, availableCapital, largestContract } = input;

  // CIDB uses the LOWEST qualifying metric
  const gradeByTurnover = getGradeFromTurnover(bestTurnover);
  const gradeByCapital = getGradeFromCapital(availableCapital);
  const gradeByContract = getGradeFromContract(largestContract);

  const grade = Math.min(gradeByTurnover, gradeByCapital, gradeByContract);

  // Calculate shortfall for next grade
  const nextGrade = grade < 9 ? grade + 1 : undefined;
  const nextGradeData = nextGrade ? {
    grade: nextGrade,
    shortfall: {
      turnover: Math.max(0, TURNOVER_THRESHOLDS[nextGrade] - bestTurnover),
      capital: Math.max(0, CAPITAL_THRESHOLDS[nextGrade] - availableCapital),
      contract: Math.max(0, CONTRACT_THRESHOLDS[nextGrade] - largestContract),
    },
  } : undefined;

  return {
    grade,
    maxContractValue: MAX_CONTRACT_VALUES[grade],
    nextGrade: nextGradeData,
    reason: explainGrade(grade, gradeByTurnover, gradeByCapital, gradeByContract),
  };
}
Enter fullscreen mode Exit fullscreen mode

The key insight: identify the limiting factor. If turnover qualifies for Grade 7 but capital only qualifies for Grade 5, the contractor gets Grade 5.

Thresholds as Lookup Tables

// CIDB Practice Note 21 thresholds (simplified)
const TURNOVER_THRESHOLDS: Record<number, number> = {
  1: 200_000,
  2: 500_000,
  3: 2_000_000,
  4: 4_000_000,
  5: 6_500_000,
  6: 13_000_000,
  7: 40_000_000,
  8: 130_000_000,
  9: Infinity,
};

const MAX_CONTRACT_VALUES: Record<number, number> = {
  1: 200_000,
  2: 650_000,
  3: 2_000_000,
  4: 4_000_000,
  5: 6_500_000,
  6: 13_000_000,
  7: 40_000_000,
  8: 130_000_000,
  9: Infinity,
};
Enter fullscreen mode Exit fullscreen mode

The UI Component

We built an interactive form with instant feedback:

// components/tools/cidb/GradeForm.tsx
'use client';

import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";

export function GradeForm({ onCalculate }: GradeFormProps) {
  const form = useForm<CidbInput>({
    resolver: zodResolver(cidbSchema),
    defaultValues: {
      classOfWorks: "",
      bestTurnover: 0,
      availableCapital: 0,
      largestContract: 0,
    },
  });

  function onSubmit(values: CidbInput) {
    const result = calculateCidbGrade(values);
    onCalculate(result);
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      <Select name="classOfWorks">
        <SelectItem value="GB">General Building</SelectItem>
        <SelectItem value="CE">Civil Engineering</SelectItem>
        {/* ... */}
      </Select>

      <Input 
        type="number" 
        placeholder="Best Annual Turnover (R)"
        {...form.register("bestTurnover", { valueAsNumber: true })}
      />

      <Button type="submit">Calculate Grade</Button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Result Display with Upgrade Path

The magic is in showing users exactly what they need to reach the next grade:

// components/tools/cidb/GradeResult.tsx
export function GradeResult({ result }: { result: CidbOutput }) {
  return (
    <div>
      <div className="text-center">
        <Badge>Grade {result.grade}</Badge>
        <p>Max Contract Value: {formatCurrency(result.maxContractValue)}</p>
      </div>

      {result.nextGrade && (
        <div className="border-t mt-6 pt-6">
          <h3>Path to Grade {result.nextGrade.grade}</h3>

          {result.nextGrade.shortfall.turnover > 0 && (
            <div className="flex justify-between">
              <span>Additional Turnover Needed:</span>
              <span className="font-bold">
                {formatCurrency(result.nextGrade.shortfall.turnover)}
              </span>
            </div>
          )}

          {/* Show capital and contract shortfalls similarly */}
        </div>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Tool #2: B-BBEE Points Calculator

B-BBEE (Broad-Based Black Economic Empowerment) is complex. Companies score points across 5 pillars:

interface BbbeeInput {
  // Ownership (25 points)
  blackOwnership: number;
  blackWomenOwnership: number;

  // Management (19 points)
  blackExecutives: number;
  blackSeniorManagers: number;

  // Skills Development (20 points + 5 bonus)
  skillsSpend: number;
  targetSkillsSpend: number;

  // Enterprise Development (40 points + 4 bonus)
  procurementSpend: number;
  bbbeeCompliantSpend: number;

  // Socio-Economic Development (5 points)
  sedContributions: number;
}

interface BbbeeOutput {
  totalScore: number;
  level: number; // 1-8
  procurementRecognition: number; // e.g., 135% for Level 1
  breakdown: {
    ownership: number;
    management: number;
    skills: number;
    enterprise: number;
    sed: number;
  };
  gaps: {
    element: string;
    current: number;
    target: number;
    impact: string;
  }[];
}
Enter fullscreen mode Exit fullscreen mode

The Calculation Engine

export function calculateBbbee(input: BbbeeInput): BbbeeOutput {
  const scores = {
    ownership: calculateOwnershipScore(input),
    management: calculateManagementScore(input),
    skills: calculateSkillsScore(input),
    enterprise: calculateEnterpriseScore(input),
    sed: calculateSedScore(input),
  };

  const totalScore = Object.values(scores).reduce((sum, s) => sum + s, 0);

  // Determine level (requires 40% on priority elements)
  const level = determineBbbeeLevel(totalScore, scores);

  // Calculate procurement recognition
  const procurementRecognition = RECOGNITION_LEVELS[level];

  // Identify improvement opportunities
  const gaps = identifyGaps(input, scores);

  return {
    totalScore,
    level,
    procurementRecognition,
    breakdown: scores,
    gaps,
  };
}
Enter fullscreen mode Exit fullscreen mode

Gap Analysis: The Secret Sauce

function identifyGaps(
  input: BbbeeInput, 
  scores: BbbeeScores
): Gap[] {
  const gaps: Gap[] = [];

  // Check each element
  if (scores.ownership < OWNERSHIP_TARGET) {
    const shortfall = OWNERSHIP_TARGET - scores.ownership;
    gaps.push({
      element: "Ownership",
      current: scores.ownership,
      target: OWNERSHIP_TARGET,
      impact: `${shortfall} additional points could improve your level`,
    });
  }

  // Priority elements must hit 40% threshold
  const priorityElements = ['ownership', 'skills', 'enterprise'];
  for (const element of priorityElements) {
    const score = scores[element];
    const maxScore = MAX_SCORES[element];
    const threshold = maxScore * 0.4;

    if (score < threshold) {
      gaps.push({
        element: capitalize(element),
        current: score,
        target: threshold,
        impact: "CRITICAL: Priority element not meeting 40% threshold",
      });
    }
  }

  return gaps.sort((a, b) => 
    (b.target - b.current) - (a.target - a.current)
  );
}
Enter fullscreen mode Exit fullscreen mode

Users don't just see their levelβ€”they see exactly where to invest for maximum B-BBEE improvement.

Tool #3: Joint Venture Calculator Suite

This was our most complex tool. Joint Ventures (JVs) allow companies to combine for larger tenders, but the B-BBEE and CIDB calculations are intricate.

The Data Model

// lib/store/useJvStore.ts
interface Partner {
  id: string;
  name: string;
  type: 'Lead' | 'Partner';

  // Equity
  equityShare: number; // % ownership

  // B-BBEE
  bbbeeLevel: number;
  bbbeeScore: number;

  // CIDB
  cidbGrade?: number;
  cidbClass?: string;

  // Work allocation
  workShare: number; // % of actual work
  profitShare: number; // % of profit
}

interface JvAnalysis {
  bbbeeResult: {
    consolidatedLevel: number;
    consolidatedScore: number;
    frontingRisk: 'LOW' | 'MEDIUM' | 'HIGH';
    riskReason?: string;
  };

  cidbResult: {
    grade: number;
    reason: string;
  };
}
Enter fullscreen mode Exit fullscreen mode

Consolidated B-BBEE Calculation

export function calculateConsolidatedBbbee(
  partners: Partner[]
): BbbeeResult {
  // Weighted average by equity share
  const totalScore = partners.reduce((sum, p) => {
    return sum + (p.bbbeeScore * (p.equityShare / 100));
  }, 0);

  const consolidatedLevel = scoreToLevel(totalScore);

  // Fronting risk detection
  const frontingRisk = detectFrontingRisk(partners);

  return {
    consolidatedLevel,
    consolidatedScore: totalScore,
    frontingRisk: frontingRisk.level,
    riskReason: frontingRisk.reason,
  };
}

function detectFrontingRisk(partners: Partner[]): FrontingRisk {
  // Red flag: High equity but low work share
  for (const partner of partners) {
    const equityToWorkRatio = partner.equityShare / partner.workShare;

    if (equityToWorkRatio > 1.5) {
      return {
        level: 'HIGH',
        reason: `${partner.name} has ${partner.equityShare}% equity but only ${partner.workShare}% work allocation. This may trigger fronting scrutiny.`,
      };
    }
  }

  // Warning: Profit share doesn't match work share
  for (const partner of partners) {
    const workToProfitRatio = partner.workShare / partner.profitShare;

    if (Math.abs(workToProfitRatio - 1) > 0.3) {
      return {
        level: 'MEDIUM',
        reason: `Work share (${partner.workShare}%) and profit share (${partner.profitShare}%) variance detected for ${partner.name}.`,
      };
    }
  }

  return { level: 'LOW' };
}
Enter fullscreen mode Exit fullscreen mode

CIDB JV Grade Upgrade Logic

CIDB allows JVs to tender one grade higher:

export function calculateJvCidbGrade(partners: Partner[]): CidbResult {
  const leadPartner = partners.find(p => p.type === 'Lead');
  const otherPartners = partners.filter(p => p.type !== 'Lead');

  if (!leadPartner?.cidbGrade) {
    return { grade: 0, reason: "Lead partner CIDB grade required" };
  }

  // Standard upgrade: Two Grade N = Grade N+1
  const allSameGrade = otherPartners.every(
    p => p.cidbGrade === leadPartner.cidbGrade
  );

  if (allSameGrade && partners.length >= 2) {
    return {
      grade: leadPartner.cidbGrade + 1,
      reason: `Upgraded from Grade ${leadPartner.cidbGrade} (two or more partners)`,
    };
  }

  // Alternative: Lead at N, two partners at N-1
  const partnersOneBelowLead = otherPartners.filter(
    p => p.cidbGrade === leadPartner.cidbGrade - 1
  );

  if (partnersOneBelowLead.length >= 2) {
    return {
      grade: leadPartner.cidbGrade + 1,
      reason: `Upgraded from Grade ${leadPartner.cidbGrade} (lead at N, two partners at N-1)`,
    };
  }

  // No upgrade
  return {
    grade: leadPartner.cidbGrade,
    reason: "No upgrade criteria met",
  };
}
Enter fullscreen mode Exit fullscreen mode

State Management with Zustand

We used Zustand for client-side JV state:

// lib/store/useJvStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface JvStore {
  partners: Partner[];
  bbbeeResult: BbbeeResult | null;
  cidbResult: CidbResult | null;

  // Actions
  addPartner: (partner: Partner) => void;
  updatePartner: (id: string, updates: Partial<Partner>) => void;
  removePartner: (id: string) => void;
  runAnalysis: () => void;
}

export const useJvStore = create<JvStore>()(
  persist(
    (set, get) => ({
      partners: [],
      bbbeeResult: null,
      cidbResult: null,

      addPartner: (partner) => 
        set((state) => ({ partners: [...state.partners, partner] })),

      updatePartner: (id, updates) =>
        set((state) => ({
          partners: state.partners.map(p => 
            p.id === id ? { ...p, ...updates } : p
          ),
        })),

      removePartner: (id) =>
        set((state) => ({
          partners: state.partners.filter(p => p.id !== id),
        })),

      runAnalysis: () => {
        const { partners } = get();

        if (partners.length === 0) return;

        const bbbeeResult = calculateConsolidatedBbbee(partners);
        const cidbResult = calculateJvCidbGrade(partners);

        set({ bbbeeResult, cidbResult });
      },
    }),
    { name: 'jv-store' }
  )
);
Enter fullscreen mode Exit fullscreen mode

This persists JV data across sessions, so users can build complex JV structures over time.

Tool #4: AI Proposal Generator

This was our first foray into generative AI for tender documents.

The Architecture

// lib/ai/proposal-generator.ts
import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});

interface ProposalRequest {
  tenderTitle: string;
  tenderDescription: string;
  companyName: string;
  companyProfile: string;
  documentType: 'cover-letter' | 'executive-summary' | 'capability-statement';
}

export async function generateProposal(
  request: ProposalRequest
): Promise<string> {
  const systemPrompt = getSystemPrompt(request.documentType);
  const userPrompt = buildUserPrompt(request);

  const message = await client.messages.create({
    model: 'claude-sonnet-4-20250514',
    max_tokens: 2000,
    system: systemPrompt,
    messages: [{
      role: 'user',
      content: userPrompt,
    }],
  });

  return message.content[0].text;
}
Enter fullscreen mode Exit fullscreen mode

The Prompts: Domain-Specific Instructions

function getSystemPrompt(documentType: string): string {
  const prompts = {
    'cover-letter': `You are an expert tender response writer for South African government procurement.

Generate a professional cover letter that:
- Addresses the tender requirements explicitly
- Demonstrates understanding of government procurement regulations (PPPFA, PFMA)
- Highlights B-BBEE compliance
- Uses formal business language
- Includes standard SBD references
- Is exactly 1 page (300-400 words)

Do NOT use placeholder text. Use the company information provided.`,

    'executive-summary': `You are creating an executive summary for a tender response.

The summary must:
- Open with a strong value proposition
- Outline the proposed solution/approach
- Highlight company strengths and differentiators
- Reference relevant experience
- Be 2-3 pages (600-800 words)
- Use bullet points for key advantages
- Maintain professional tone

Focus on BENEFITS, not just features.`,

    'capability-statement': `You are writing a capability statement for a tender.

Include:
- Company overview and history
- Core competencies
- Relevant project experience (3-5 case studies)
- Team qualifications
- Quality assurance processes
- Health and safety commitments
- B-BBEE status and transformation credentials

Length: 3-4 pages (900-1200 words)`,
  };

  return prompts[documentType];
}
Enter fullscreen mode Exit fullscreen mode

Streaming for Better UX

We implemented streaming to show content as it's generated:

// components/tools/template-generator/TemplateTool.tsx
'use client';

import { useState } from 'react';

export function TemplateTool() {
  const [content, setContent] = useState('');
  const [isGenerating, setIsGenerating] = useState(false);

  async function handleGenerate(data: ProposalRequest) {
    setIsGenerating(true);
    setContent('');

    const response = await fetch('/api/tools/generate-proposal', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });

    const reader = response.body?.getReader();
    const decoder = new TextDecoder();

    while (true) {
      const { done, value } = await reader!.read();
      if (done) break;

      const chunk = decoder.decode(value);
      setContent(prev => prev + chunk);
    }

    setIsGenerating(false);
  }

  return (
    <div>
      <ProposalForm onSubmit={handleGenerate} />

      {isGenerating && (
        <div className="flex items-center gap-2">
          <Loader2 className="animate-spin" />
          <span>Generating...</span>
        </div>
      )}

      {content && (
        <div className="prose max-w-none">
          <ReactMarkdown>{content}</ReactMarkdown>
          <Button onClick={() => copyToClipboard(content)}>
            Copy to Clipboard
          </Button>
        </div>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Cost Optimization

AI calls can get expensive. We implemented caching:

import { createHash } from 'crypto';
import { redis } from '@/lib/redis';

async function generateProposal(
  request: ProposalRequest
): Promise<string> {
  // Generate cache key from request
  const cacheKey = `proposal:${hashRequest(request)}`;

  // Check cache
  const cached = await redis.get(cacheKey);
  if (cached) {
    return cached;
  }

  // Generate new
  const result = await callAnthropicAPI(request);

  // Cache for 7 days
  await redis.setex(cacheKey, 604800, result);

  return result;
}

function hashRequest(request: ProposalRequest): string {
  const content = JSON.stringify(request);
  return createHash('sha256').update(content).digest('hex');
}
Enter fullscreen mode Exit fullscreen mode

This reduced our AI costs by ~60% for similar tender requests.

Tool #5: Tender Value Estimator

This tool uses historical award data to predict tender values.

The Data Pipeline

We scrape OCDS (Open Contracting Data Standard) feeds:

// scripts/import-ocds-data.ts
interface OcdsRelease {
  id: string;
  tender: {
    title: string;
    description: string;
    value: {
      amount: number;
      currency: string;
    };
  };
  awards?: Array<{
    value: {
      amount: number;
      currency: string;
    };
    date: string;
  }>;
}

async function importOcdsData() {
  const releases = await fetchOcdsReleases();

  for (const release of releases) {
    // Extract and normalize
    const award = release.awards?.[0];
    if (!award) continue;

    await db.historicalAward.create({
      data: {
        ocdsId: release.id,
        title: release.tender.title,
        description: release.tender.description,
        estimatedValue: release.tender.value.amount,
        awardValue: award.value.amount,
        currency: award.value.currency,
        awardDate: new Date(award.date),
        category: classifyTender(release.tender.title),
        province: extractProvince(release.tender.title),
      },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The Estimation Algorithm

// lib/tools/value-estimator.ts
interface EstimationInput {
  title: string;
  description: string;
  category: string;
  province: string;
}

interface EstimationOutput {
  estimatedMin: number;
  estimatedMax: number;
  estimatedMedian: number;
  confidenceScore: number;
  sampleSize: number;
  comparables: HistoricalAward[];
}

export async function estimateTenderValue(
  input: EstimationInput
): Promise<EstimationOutput> {
  // Find similar tenders
  const comparables = await findComparables(input);

  if (comparables.length < 3) {
    return {
      estimatedMin: 0,
      estimatedMax: 0,
      estimatedMedian: 0,
      confidenceScore: 0,
      sampleSize: 0,
      comparables: [],
    };
  }

  // Calculate statistics
  const values = comparables.map(c => c.awardValue).sort((a, b) => a - b);

  const estimatedMin = percentile(values, 25);
  const estimatedMedian = percentile(values, 50);
  const estimatedMax = percentile(values, 75);

  // Confidence based on sample size and variance
  const confidenceScore = calculateConfidence(values);

  return {
    estimatedMin,
    estimatedMax,
    estimatedMedian,
    confidenceScore,
    sampleSize: values.length,
    comparables: comparables.slice(0, 10), // Top 10 most similar
  };
}
Enter fullscreen mode Exit fullscreen mode

Similarity Matching with Embeddings

We use OpenAI embeddings for semantic similarity:

import OpenAI from 'openai';

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

async function findComparables(
  input: EstimationInput
): Promise<HistoricalAward[]> {
  // Generate embedding for input
  const inputText = `${input.title} ${input.description}`;
  const inputEmbedding = await generateEmbedding(inputText);

  // Find similar awards using vector similarity
  const results = await db.$queryRaw`
    SELECT *,
      1 - (embedding <=> ${inputEmbedding}::vector) AS similarity
    FROM historical_awards
    WHERE category = ${input.category}
      AND province = ${input.province}
      AND 1 - (embedding <=> ${inputEmbedding}::vector) > 0.7
    ORDER BY similarity DESC
    LIMIT 50
  `;

  return results;
}

async function generateEmbedding(text: string): Promise<number[]> {
  const response = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: text,
  });

  return response.data[0].embedding;
}
Enter fullscreen mode Exit fullscreen mode

This gives much better results than keyword matching alone.

Tool #6: Provincial Tender Heatmap

Interactive data visualization using Recharts:

// components/analytics/heatmap-dashboard.tsx
'use client';

import { useState, useEffect } from 'react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';

interface ProvinceData {
  province: string;
  tenderCount: number;
  totalValue: number;
}

export function HeatmapDashboard() {
  const [data, setData] = useState<ProvinceData[]>([]);
  const [timeRange, setTimeRange] = useState<'7d' | '30d' | '90d'>('30d');

  useEffect(() => {
    loadHeatmapData(timeRange).then(setData);
  }, [timeRange]);

  return (
    <div className="space-y-6">
      <div className="flex justify-between items-center">
        <h1>Provincial Tender Heatmap</h1>
        <Select value={timeRange} onValueChange={setTimeRange}>
          <SelectItem value="7d">Last 7 Days</SelectItem>
          <SelectItem value="30d">Last 30 Days</SelectItem>
          <SelectItem value="90d">Last 90 Days</SelectItem>
        </Select>
      </div>

      <ResponsiveContainer width="100%" height={400}>
        <BarChart data={data}>
          <XAxis dataKey="province" />
          <YAxis />
          <Tooltip />
          <Bar 
            dataKey="tenderCount" 
            fill="hsl(var(--primary))"
            radius={[8, 8, 0, 0]}
          />
        </BarChart>
      </ResponsiveContainer>

      <ProvinceGrid data={data} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Data Aggregation

// lib/analytics/heatmap.ts
export async function loadHeatmapData(
  timeRange: string
): Promise<ProvinceData[]> {
  const days = timeRange === '7d' ? 7 : timeRange === '30d' ? 30 : 90;
  const startDate = new Date();
  startDate.setDate(startDate.getDate() - days);

  const data = await db.tender.groupBy({
    by: ['province'],
    where: {
      publishedAt: { gte: startDate },
      status: 'ACTIVE',
    },
    _count: { id: true },
    _sum: { estimatedValue: true },
  });

  return data.map(d => ({
    province: d.province,
    tenderCount: d._count.id,
    totalValue: d._sum.estimatedValue || 0,
  }));
}
Enter fullscreen mode Exit fullscreen mode

Performance Optimizations

1. Memoization for Expensive Calculations

import { useMemo } from 'react';

function BbbeeCalculator({ input }: { input: BbbeeInput }) {
  const result = useMemo(() => {
    return calculateBbbee(input);
  }, [input]);

  return <BbbeeResult result={result} />;
}
Enter fullscreen mode Exit fullscreen mode

2. Web Workers for Heavy Computations

// lib/workers/calculations.worker.ts
self.addEventListener('message', (e) => {
  const { type, data } = e.data;

  switch (type) {
    case 'CALCULATE_CIDB':
      const result = calculateCidbGrade(data);
      self.postMessage({ type: 'CIDB_RESULT', result });
      break;

    case 'CALCULATE_BBBEE':
      const bbbeeResult = calculateBbbee(data);
      self.postMessage({ type: 'BBBEE_RESULT', result: bbbeeResult });
      break;
  }
});
Enter fullscreen mode Exit fullscreen mode

Usage:

const worker = new Worker('/workers/calculations.worker.js');

worker.postMessage({ type: 'CALCULATE_CIDB', data: input });

worker.addEventListener('message', (e) => {
  if (e.data.type === 'CIDB_RESULT') {
    setResult(e.data.result);
  }
});
Enter fullscreen mode Exit fullscreen mode

3. Database Indexes

model HistoricalAward {
  id          String   @id @default(cuid())
  category    String
  province    String
  awardValue  Float
  awardDate   DateTime
  embedding   Unsupported("vector(1536)")?

  @@index([category, province])
  @@index([awardDate])
}
Enter fullscreen mode Exit fullscreen mode

Testing: Validation Against Real Data

We validated our calculators against real government tenders:

// tests/cidb-calculator.test.ts
import { calculateCidbGrade } from '@/lib/tools/cidb-calculator';

describe('CIDB Calculator', () => {
  it('should calculate Grade 5 for typical mid-size contractor', () => {
    const input = {
      classOfWorks: 'GB',
      bestTurnover: 7_000_000,
      availableCapital: 1_500_000,
      largestContract: 3_000_000,
    };

    const result = calculateCidbGrade(input);

    expect(result.grade).toBe(5);
    expect(result.maxContractValue).toBe(6_500_000);
  });

  it('should identify capital as limiting factor', () => {
    const input = {
      classOfWorks: 'CE',
      bestTurnover: 50_000_000, // Qualifies for Grade 7
      availableCapital: 500_000, // Only qualifies for Grade 2
      largestContract: 10_000_000, // Qualifies for Grade 6
    };

    const result = calculateCidbGrade(input);

    expect(result.grade).toBe(2);
    expect(result.reason).toContain('capital');
  });
});
Enter fullscreen mode Exit fullscreen mode

User Feedback Loop

We added feedback collection to improve accuracy:

export function ToolResult({ result }: { result: ToolOutput }) {
  const [feedback, setFeedback] = useState<'helpful' | 'not-helpful' | null>(null);

  async function submitFeedback(rating: 'helpful' | 'not-helpful') {
    setFeedback(rating);

    await fetch('/api/tools/feedback', {
      method: 'POST',
      body: JSON.stringify({
        toolName: 'cidb-calculator',
        input: result.input,
        output: result.output,
        rating,
      }),
    });
  }

  return (
    <div>
      <ResultDisplay result={result} />

      <div className="mt-4 flex gap-2">
        <Button
          variant={feedback === 'helpful' ? 'default' : 'outline'}
          onClick={() => submitFeedback('helpful')}
        >
          πŸ‘ Helpful
        </Button>
        <Button
          variant={feedback === 'not-helpful' ? 'default' : 'outline'}
          onClick={() => submitFeedback('not-helpful')}
        >
          πŸ‘Ž Not Helpful
        </Button>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This feedback helps us refine algorithms and prompts.

Lessons Learned

1. Explain Everything

Users don't just want answersβ€”they want to understand the logic:

// Good: Explains the "why"
{
  grade: 5,
  reason: "Your grade is limited by available capital (R1.5M). Turnover and contract history qualify for Grade 6, but capital only meets Grade 5 threshold (R6.5M required for Grade 6)."
}

// Bad: Just the number
{
  grade: 5
}
Enter fullscreen mode Exit fullscreen mode

2. Progressive Enhancement for Calculators

Start simple, add complexity:

// v1: Basic calculator
function calculateBbbee(ownership: number): number {
  return ownership * 0.25; // Simplified
}

// v2: Add all elements
function calculateBbbee(input: BbbeeInput): BbbeeOutput {
  // Full scorecard calculation
}

// v3: Add gap analysis
function calculateBbbee(input: BbbeeInput): BbbeeOutput {
  const result = calculate(input);
  result.gaps = identifyGaps(input);
  return result;
}
Enter fullscreen mode Exit fullscreen mode

3. Cache Aggressively for AI Tools

const CACHE_DURATION = {
  'proposal-generation': 7 * 24 * 60 * 60, // 7 days
  'value-estimation': 24 * 60 * 60, // 1 day
  'historical-data': 30 * 24 * 60 * 60, // 30 days
};
Enter fullscreen mode Exit fullscreen mode

4. Real-Time Validation > Post-Submit Errors

<Input
  type="number"
  {...register("blackOwnership", {
    validate: (value) => {
      if (value < 0) return "Cannot be negative";
      if (value > 100) return "Cannot exceed 100%";
      return true;
    }
  })}
/>
Enter fullscreen mode Exit fullscreen mode

What's Next?

We're working on:

  1. ML-powered tender matching - Recommend tenders based on company capabilities
  2. Automated compliance checking - Scan uploaded documents for completeness
  3. Real-time collaboration - Multiple team members work on JV analysis together
  4. Mobile apps - Tools on the go
  5. API access - Let accounting firms integrate our calculators

Open Source Plans

We're planning to open source:

  • βœ… CIDB calculation library
  • βœ… B-BBEE calculator component
  • βœ… South African public holiday data (2025-2030)
  • πŸ”„ JV fronting risk detection algorithm
  • πŸ”„ Tender classification ML model

Would this be useful for your projects? Let me know!

Conclusion

Building these tools taught us that good tools educate while they calculate. Users shouldn't just get an answerβ€”they should understand the regulations, see where they stand, and learn what to improve.

The South African tender market is worth R500+ billion annually. By providing free, high-quality tools, we're helping businesses make data-driven decisions about which opportunities to pursue.

If you're building similar calculation-heavy applications, I hope this deep dive helps. Questions? Drop them in the comments!


Tech Stack:

  • Next.js 14 + TypeScript
  • Zustand for state management
  • Recharts for data viz
  • Anthropic Claude for AI generation
  • OpenAI embeddings for similarity
  • PostgreSQL + Prisma
  • Redis for caching

Try the tools: tenders-sa.org/tools


What tools would help your business win more work? Share your ideas below!

Top comments (0)