Alerts react to things that already happened. Predictions anticipate things that will happen. The deal prediction system classifies open deals into three risk categories, runs nightly, and surfaces recommendations before problems become crises.
Three Prediction Types
// src/domain/predictions/dealPredictionService.ts
// 1. slip_risk: Deal will miss its close date
// 2. loss_risk: Deal matches patterns of past losses
// 3. champion_cold: Key contact has gone silent or is missingSlip Risk
A deal is at slip risk when it’s stalling — the close date is approaching but nothing is happening:
// Slip risk triggers when:
// close_date dimension ≥60 OR (stage_duration ≥60 AND activity_recency ≥60)
if (
deal.dimensions.close_date >= 60 ||
(deal.dimensions.stage_duration >= 60 && deal.dimensions.activity_recency >= 60)
) {
const confidence = Math.round(
deal.dimensions.close_date * 0.6 + deal.dimensions.stage_duration * 0.4
);
predictions.push({
predictionType: 'slip_risk',
confidence: Math.min(100, Math.max(0, confidence)),
predictedDate: computePredictedSlipDate(deal, stageBaselines),
recommendedAction: 'Review timeline and re-engage stakeholders',
});
}Close date dimension >= 60 means the close date is within 2 weeks or already passed. Stage duration >= 60 means the deal has been in its current stage longer than average for that stage. Activity recency >= 60 means no activity in 2+ weeks.
The confidence formula weights close date (60%) more than stage duration (40%) because a looming close date is a stronger predictor of slip than stage duration alone.
Loss Risk
A deal is at loss risk when it matches patterns from your historical losses:
// Loss risk triggers when:
// risk_score ≥60 AND matching loss patterns
const patternStrength = lossPatterns.get(deal.deal_id)?.strength || 0;
if (deal.risk_score >= 60 && patternStrength > 0) {
const confidence = Math.round(
deal.risk_score * 0.6 + patternStrength * 0.4
);
predictions.push({
predictionType: 'loss_risk',
confidence,
recommendedAction: 'Analyze loss pattern and adjust strategy',
actionParams: {
risk_score: deal.risk_score,
risk_tier: deal.risk_tier,
similar_loss_count: Math.ceil(patternStrength / 25),
},
});
}Pattern strength measures how similar this deal’s characteristics are to your past losses. It considers industry, company size, deal value, stage duration, and contact coverage. A strength of 100 means “this deal looks exactly like deals you’ve lost.”
Champion Cold
A deal without an active champion is a deal without an advocate:
// Champion cold triggers when:
// contact_coverage ≥40 OR no champions at all
if (deal.dimensions.contact_coverage >= 40 || deal.champion_count === 0) {
const confidence = Math.round(
deal.dimensions.contact_coverage * 0.5 + deal.dimensions.activity_recency * 0.5
);
predictions.push({
predictionType: 'champion_cold',
confidence,
recommendedAction: 'Identify and engage executive champion',
});
}Contact coverage >= 40 means fewer than 60% of the buying committee is covered. Champion count = 0 is the worst case — no one inside the company is advocating for the deal. This prediction fires even for new deals where champions haven’t been identified yet.
The Risk Scoring Dimensions
Each deal is scored across six dimensions, each 0-100:
| Dimension | What It Measures | High = Bad |
|---|---|---|
stage_duration | Days in current stage vs. baseline | Stalling |
activity_recency | Days since last activity | Going cold |
close_date | Days until close date (or past due) | Slipping |
signal_decay | How stale intent signals are | Losing interest |
contact_coverage | % of buying committee covered | Single-threaded |
competitor_threat | Competitor presence on deal | Competitive risk |
The overall risk score is a weighted combination:
| Component | Weight | Description |
|---|---|---|
| Stage Duration | 25% | Days in current stage vs. baseline |
| Activity Recency | 25% | Days since last activity |
| Close Date | 20% | Days until close date or past due |
| Signal Decay | 10% | How stale intent signals are |
| Contact Coverage | 10% | % of buying committee covered |
| Competitor Threat | 10% | Competitor presence on deal |
risk_score = stage_duration x 0.25 + activity_recency x 0.25 + close_date x 0.20
+ signal_decay x 0.10 + contact_coverage x 0.10 + competitor_threat x 0.10Risk tiers:
- CRITICAL (>=80): Immediate attention needed
- HIGH (>=60): At risk, action recommended
- MEDIUM (>=40): Monitor closely
- LOW (under 40): On track
Nightly Processing
Predictions run nightly via cron with sophisticated timeout management:
// src/pages/api/cron/process-deal-predictions.ts
const REQUEST_DEADLINE_MS = 25_000; // Total cron budget: 25 seconds
const USER_TIMEOUT_MS = 20_000; // Per-user timeout: 20 seconds
for (const { user_id: userId } of usersRes.rows) {
if (Date.now() - startTime > REQUEST_DEADLINE_MS) {
timedOut = true;
break;
}
const count = await Promise.race([
processUserPredictions(userId),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`User ${userId} timeout`)), USER_TIMEOUT_MS)
),
]);
}Promise.race is the timeout pattern — whichever resolves first wins. If processUserPredictions takes longer than 20 seconds, the timeout promise rejects and we move to the next user.
The REQUEST_DEADLINE_MS check prevents the cron from exceeding its overall time budget (AWS Lambda/Amplify has a 30-second limit for API routes).
Confidence History
Predictions aren’t one-shot. The confidence_history JSONB column tracks how confidence changes over time:
[
{ "confidence": 45, "timestamp": "2024-03-15T00:00:00Z" },
{ "confidence": 62, "timestamp": "2024-03-16T00:00:00Z" },
{ "confidence": 71, "timestamp": "2024-03-17T00:00:00Z" }
]A rising confidence trend means the situation is getting worse. A falling trend means the rep’s actions are working. The UI can show this as a sparkline.
Upsert Pattern
Predictions are upserted nightly. The same deal can have the same prediction type updated with new confidence:
INSERT INTO deal_predictions (user_id, deal_id, prediction_type, confidence, ...)
VALUES ($1, $2, $3, $4, ...)
ON CONFLICT (user_id, deal_id, prediction_type) DO UPDATE SET
confidence = EXCLUDED.confidence,
confidence_history = deal_predictions.confidence_history || $5::jsonb,
updated_at = NOW()The || operator appends the new confidence entry to the existing history array.
Key Takeaways
-
Three prediction types catch different failure modes: slip (timing), loss (pattern matching), and champion cold (relationship gaps).
-
Six risk dimensions combine into an overall risk score. Each dimension is 0-100.
-
Nightly cron with timeout budgets processes all users without exceeding infrastructure limits.
-
Confidence history tracks trends over time — rising confidence = worsening situation.
-
Upsert with history append preserves the trend while updating the current score.
Next chapter: autonomous actions — the AI-drafted responses that turn predictions into action.