Opportunity Surface Service

The Opportunity Surface Service manages Fidus's dynamic dashboard, curating and ranking opportunity cards based on relevance, context, and user behavior.

What is the Opportunity Surface?

The Opportunity Surface is Fidus's answer to the traditional static dashboard. Instead of showing fixed widgets, it dynamically displays context-relevant opportunity cards that the user can act on or dismiss.

Key Differences

Traditional Dashboard

  • ❌ Fixed widgets hardcoded in layout
  • ❌ Same view for all users
  • ❌ Static all day long
  • ❌ User must seek relevant info

Opportunity Surface

  • ✅ Dynamic cards based on relevance
  • ✅ Personalized per user
  • ✅ Changes throughout the day
  • ✅ Relevant info surfaces proactively

Service Responsibilities

1. Query Opportunities from Domains

Poll domain supervisors for potential opportunities (budget alerts, calendar conflicts, travel reminders, etc.)

2. Rank by Relevance

Score opportunities based on time, location, user behavior, and domain confidence

3. Filter Dismissed Opportunities

Exclude opportunities user has already dismissed (unless they become relevant again)

4. Manage User Dismissals

Track when users dismiss cards and learn from dismissal patterns

Opportunity Lifecycle

From Domain Event to Dashboard Card

1
Domain Emits Trigger
Finance domain detects budget at 95%, emits BUDGET_EXCEEDED trigger
2
Service Evaluates Relevance
Check: Is budget period ending soon? Is user actively spending? Time context?
3
Ranks Against Other Opportunities
Compare with calendar conflicts, travel reminders, etc. Sort by relevance score
4
Renders as OpportunityCard
Card appears on dashboard with urgency styling, data, and action buttons
5
User Dismisses
User swipes card away. Service persists dismissal state and learns from pattern

Ranking Algorithm

The service uses a weighted scoring algorithm to rank opportunities:

interface OpportunityScore {
  timeRelevance: number;      // 0-100
  locationRelevance: number;  // 0-100
  userHistory: number;        // 0-100
  domainConfidence: number;   // 0-100 (from domain)
  urgency: number;            // 0-100 (critical=100, high=75, medium=50, low=25)
}

function calculateRelevance(opportunity: Opportunity): number {
  const scores = {
    timeRelevance: calculateTimeRelevance(opportunity),
    locationRelevance: calculateLocationRelevance(opportunity),
    userHistory: calculateUserHistoryScore(opportunity),
    domainConfidence: opportunity.confidence * 100,
    urgency: urgencyToScore(opportunity.urgency)
  };

  // Weighted average
  const weights = {
    timeRelevance: 0.25,
    locationRelevance: 0.15,
    userHistory: 0.20,
    domainConfidence: 0.25,
    urgency: 0.15
  };

  return (
    scores.timeRelevance * weights.timeRelevance +
    scores.locationRelevance * weights.locationRelevance +
    scores.userHistory * weights.userHistory +
    scores.domainConfidence * weights.domainConfidence +
    scores.urgency * weights.urgency
  );
}

// Example: Budget Alert
const budgetAlert = {
  type: 'BUDGET_EXCEEDED',
  confidence: 0.95,
  urgency: 'high',
  context: {
    daysLeftInPeriod: 3,
    percentUsed: 95,
    recentActivity: true
  }
};

const scores = {
  timeRelevance: 90,        // End of month
  locationRelevance: 100,   // Not location-dependent
  userHistory: 85,          // User checks budgets regularly
  domainConfidence: 95,     // From domain
  urgency: 75               // High urgency
};

const relevance =
  90 * 0.25 +    // 22.5
  100 * 0.15 +   // 15.0
  85 * 0.20 +    // 17.0
  95 * 0.25 +    // 23.75
  75 * 0.15;     // 11.25
  // = 89.5 (very high relevance)

Time Relevance Calculation

function calculateTimeRelevance(opportunity: Opportunity): number {
  const now = new Date();
  const currentHour = now.getHours();

  switch (opportunity.type) {
    case 'CALENDAR_CONFLICT':
      // More relevant as event approaches
      const hoursUntilEvent = opportunity.hoursUntil;
      if (hoursUntilEvent < 1) return 100;
      if (hoursUntilEvent < 2) return 90;
      if (hoursUntilEvent < 24) return 70;
      return 40;

    case 'BUDGET_EXCEEDED':
      // More relevant at end of billing period
      const daysLeft = opportunity.daysLeftInPeriod;
      if (daysLeft <= 2) return 95;
      if (daysLeft <= 5) return 80;
      if (daysLeft <= 10) return 60;
      return 40;

    case 'WEATHER_ALERT':
      // More relevant in morning before leaving home
      if (currentHour >= 6 && currentHour <= 9) return 90;
      if (currentHour >= 10 && currentHour <= 12) return 70;
      return 30;

    case 'TRAVEL_CHECKIN':
      // More relevant as departure approaches
      const hoursUntilDeparture = opportunity.hoursUntilDeparture;
      if (hoursUntilDeparture < 24) return 100;
      if (hoursUntilDeparture < 48) return 70;
      return 40;

    default:
      return 50;  // Neutral relevance
  }
}

User History Scoring

function calculateUserHistoryScore(opportunity: Opportunity): number {
  const userHistory = getUserHistory(opportunity.userId);

  // Has user engaged with similar opportunities?
  const similarEngagements = userHistory.engagements.filter(
    e => e.type === opportunity.type
  );
  const engagementRate = similarEngagements.length > 0
    ? similarEngagements.filter(e => e.action !== 'dismiss').length / similarEngagements.length
    : 0.5;  // Neutral if no history

  // Has user dismissed this type recently?
  const recentDismissals = userHistory.dismissals.filter(
    d => d.type === opportunity.type &&
         d.timestamp > Date.now() - 24 * 60 * 60 * 1000  // Last 24h
  );
  const dismissalPenalty = recentDismissals.length * 20;  // -20 per dismissal

  // Calculate base score from engagement
  let score = engagementRate * 100;

  // Apply dismissal penalty
  score = Math.max(0, score - dismissalPenalty);

  return score;
}

// Example
const user = {
  engagements: [
    { type: 'BUDGET_EXCEEDED', action: 'view', timestamp: ... },
    { type: 'BUDGET_EXCEEDED', action: 'adjust', timestamp: ... },
    { type: 'WEATHER_ALERT', action: 'dismiss', timestamp: ... }
  ],
  dismissals: [
    { type: 'WEATHER_ALERT', timestamp: Date.now() - 3 * 60 * 60 * 1000 }
  ]
};

// Budget alert: 2/2 engagements, 0 dismissals = 100 score
// Weather alert: 0/1 engagements, 1 recent dismissal = 0 - 20 = 0 score

API Endpoints

GET /opportunities

Fetch ranked list of opportunities for current user:

GET /api/opportunities
Headers:
  Authorization: Bearer <token>

Query Parameters:
  limit: number (default: 10)
  minRelevance: number (default: 60)
  includeDismissed: boolean (default: false)

Response:
{
  "opportunities": [
    {
      "id": "opp-123",
      "type": "BUDGET_EXCEEDED",
      "title": "Food Budget Exceeded",
      "description": "You've spent 950 EUR of 1000 EUR budget with 3 days left in October",
      "urgency": "high",
      "relevance": 89.5,
      "domain": "finance",
      "componentType": "budget-alert-card",
      "props": {
        "category": "food",
        "spent": 950,
        "limit": 1000,
        "daysLeft": 3,
        "period": "monthly"
      },
      "actions": [
        {
          "id": "view-transactions",
          "label": "View Transactions",
          "primary": true
        },
        {
          "id": "adjust-budget",
          "label": "Adjust Budget",
          "primary": false
        }
      ],
      "createdAt": "2025-10-29T14:30:00Z",
      "expiresAt": "2025-10-31T23:59:59Z"
    },
    {
      "id": "opp-124",
      "type": "CALENDAR_CONFLICT",
      "title": "Meeting Conflict",
      "description": "Two meetings overlap at 2pm today",
      "urgency": "medium",
      "relevance": 78.2,
      "domain": "calendar",
      "componentType": "calendar-conflict-card",
      "props": {
        "conflictingEvents": [
          {
            "id": "evt-1",
            "title": "Team Standup",
            "start": "2025-10-29T14:00:00Z"
          },
          {
            "id": "evt-2",
            "title": "Client Call",
            "start": "2025-10-29T14:00:00Z"
          }
        ]
      },
      "actions": [
        {
          "id": "resolve-conflict",
          "label": "Resolve Conflict",
          "primary": true
        }
      ],
      "createdAt": "2025-10-29T13:45:00Z",
      "expiresAt": "2025-10-29T14:00:00Z"
    }
  ],
  "totalCount": 5,
  "hasMore": false,
  "nextRelevanceThreshold": 55.0
}

POST /opportunities/:id/dismiss

Dismiss an opportunity card:

POST /api/opportunities/opp-123/dismiss
Headers:
  Authorization: Bearer <token>

Body:
{
  "reason": "not-interested" | "already-handled" | "not-relevant" | "other",
  "feedback": "Optional feedback text"
}

Response:
{
  "success": true,
  "opportunityId": "opp-123",
  "dismissedAt": "2025-10-29T14:35:00Z",
  "willReappear": false,
  "message": "Budget alert dismissed. You won't see this again for this period."
}

POST /opportunities/:id/engage

Track user engagement with opportunity:

POST /api/opportunities/opp-123/engage
Headers:
  Authorization: Bearer <token>

Body:
{
  "action": "view" | "click" | "complete",
  "actionId": "view-transactions",  // Optional: which action button
  "duration": 45  // Optional: seconds spent on opportunity
}

Response:
{
  "success": true,
  "opportunityId": "opp-123",
  "engagedAt": "2025-10-29T14:36:00Z",
  "message": "Engagement recorded"
}

Real-time Updates

The Opportunity Surface supports real-time updates via WebSocket or Server-Sent Events:

// Client-side WebSocket connection
const ws = new WebSocket('wss://api.fidus.ai/opportunities/stream');

ws.onmessage = (event) => {
  const message = JSON.parse(event.data);

  switch (message.type) {
    case 'OPPORTUNITY_ADDED':
      // New opportunity appeared
      addOpportunityToSurface(message.opportunity);
      break;

    case 'OPPORTUNITY_UPDATED':
      // Existing opportunity changed (e.g., relevance score)
      updateOpportunity(message.opportunityId, message.changes);
      break;

    case 'OPPORTUNITY_EXPIRED':
      // Opportunity no longer relevant
      removeOpportunity(message.opportunityId);
      break;

    case 'RELEVANCE_RERANKED':
      // Opportunities reordered
      reorderOpportunities(message.newOrder);
      break;
  }
};

// Example message
{
  "type": "OPPORTUNITY_ADDED",
  "opportunity": {
    "id": "opp-125",
    "type": "TRAVEL_CHECKIN",
    "title": "Check-in Available",
    "description": "Your flight to Paris departs in 24 hours. Check-in is now open.",
    "urgency": "medium",
    "relevance": 85.0,
    "domain": "travel",
    // ... rest of opportunity
  }
}

Opportunity Data Structure

interface Opportunity {
  // Unique identifier
  id: string;

  // Opportunity type (domain-specific)
  type: OpportunityType;

  // Display information
  title: string;
  description: string;

  // Relevance information
  urgency: 'low' | 'medium' | 'high' | 'critical';
  relevance: number;  // Calculated score (0-100)

  // Source domain
  domain: string;

  // UI rendering
  componentType: string;  // Component registry lookup
  props: Record<string, unknown>;

  // Actions user can take
  actions: Array<{
    id: string;
    label: string;
    primary: boolean;
    icon?: string;
  }>;

  // Lifecycle
  createdAt: string;
  expiresAt?: string;
  dismissedAt?: string;

  // Context that influenced relevance
  context?: {
    timeRelevance: number;
    locationRelevance: number;
    userHistoryScore: number;
    domainConfidence: number;
  };
}

type OpportunityType =
  | 'BUDGET_EXCEEDED'
  | 'CALENDAR_CONFLICT'
  | 'TRAVEL_CHECKIN'
  | 'WEATHER_ALERT'
  | 'MISSED_TRANSACTION'
  | 'RECURRING_EXPENSE_DETECTED'
  | 'APPOINTMENT_REMINDER'
  | 'SMART_SUGGESTION';

Persistence and State Management

The service persists opportunity state in the database:

-- opportunities table
CREATE TABLE opportunities (
  id UUID PRIMARY KEY,
  user_id UUID NOT NULL,
  type VARCHAR(50) NOT NULL,
  title TEXT NOT NULL,
  description TEXT NOT NULL,
  urgency VARCHAR(20) NOT NULL,
  relevance DECIMAL(5,2) NOT NULL,
  domain VARCHAR(50) NOT NULL,
  component_type VARCHAR(100) NOT NULL,
  props JSONB NOT NULL,
  actions JSONB NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  expires_at TIMESTAMP,
  dismissed_at TIMESTAMP,
  dismissal_reason VARCHAR(50),
  context JSONB,
  INDEX idx_user_relevance (user_id, relevance DESC),
  INDEX idx_user_created (user_id, created_at DESC)
);

-- opportunity_engagements table
CREATE TABLE opportunity_engagements (
  id UUID PRIMARY KEY,
  opportunity_id UUID REFERENCES opportunities(id),
  user_id UUID NOT NULL,
  action VARCHAR(50) NOT NULL,
  action_id VARCHAR(100),
  duration INTEGER,
  engaged_at TIMESTAMP NOT NULL DEFAULT NOW(),
  INDEX idx_opportunity (opportunity_id),
  INDEX idx_user (user_id)
);

Benefits

  • Context-Aware: Cards appear when relevant, not all the time
  • Personalized: Different users see different opportunities
  • Dynamic: Dashboard changes throughout the day
  • User-Controlled: Users dismiss cards, never auto-hide
  • Learning: System learns from engagement and dismissals
  • Real-time: New opportunities appear instantly via WebSocket

Implementation Example

// Frontend: OpportunitySurface component
import { useOpportunities } from '@/hooks/useOpportunities';

export function OpportunitySurface() {
  const { opportunities, dismiss, engage, loading } = useOpportunities({
    minRelevance: 60,
    limit: 10
  });

  const handleDismiss = async (opportunityId: string) => {
    await dismiss(opportunityId, {
      reason: 'not-interested'
    });
  };

  const handleAction = async (opportunityId: string, actionId: string) => {
    await engage(opportunityId, {
      action: 'click',
      actionId
    });
  };

  if (loading) return <LoadingSpinner />;

  return (
    <div className="opportunity-surface">
      {opportunities.length === 0 ? (
        <EmptyState message="No urgent items. You're all caught up!" />
      ) : (
        <div className="space-y-4">
          {opportunities.map((opp) => (
            <OpportunityCard
              key={opp.id}
              opportunity={opp}
              onDismiss={() => handleDismiss(opp.id)}
              onAction={(actionId) => handleAction(opp.id, actionId)}
            />
          ))}
        </div>
      )}
    </div>
  );
}

Related Documentation