DEV Community

Danilo "Jamaal" Batson
Danilo "Jamaal" Batson

Posted on

Queue Social Data Processing with Inngest + TypeScript in 15 Minutes

Queue Social Data Processing with Inngest + TypeScript in 15 Minutes

See how to build a production-ready crypto sentiment processor using background jobs. Step-by-step guide, complete source code, live demo, and deployable repo included!

Social Sentiment Processor Dashboard

Introduction

In today's fast-moving crypto market, processing sentiment data for 1000+ cryptocurrencies can easily overwhelm your server and block user requests for 30+ seconds. This tutorial shows you how to build a professional background job processing system that handles heavy workloads efficiently using modern TypeScript patterns.

What You'll Build

Background Job Processing Demo

In this tutorial, you'll create a production-ready sentiment processor that:

  • ✅ Monitors sentiment trends across hundreds of cryptocurrencies
  • ✅ Queues background processing without blocking your app
  • ✅ Automatically retries failed API calls with exponential backoff
  • ✅ Detects significant sentiment shifts and sends Discord alerts
  • ✅ Stores historical data for trend analysis

Time Investment: ~15 minutes

Skill Level: Beginner to Intermediate

What You'll Learn: Background job queues, TypeScript patterns, real-time dashboards

💡 Pro Tip: By the end, you'll have a production-ready system you can deploy to impress clients or add to your portfolio!

Why Background Jobs Matter

The Problem:

// ❌ DON'T DO THIS - Blocks the main thread
app.post('/analyze', async (req, res) => {
  const data = await fetchDataForAllCoins(); // 30+ second operation
  res.json(analysis); // User waits 30+ seconds!
});
Enter fullscreen mode Exit fullscreen mode

The Solution:

// ✅ DO THIS - Queue background processing
app.post('/analyze', async (req, res) => {
  const jobId = await inngest.send({ name: 'sentiment/process' });
  res.json({ jobId, status: 'queued' }); // Instant response!
});
Enter fullscreen mode Exit fullscreen mode

Inngest’s event-driven queues simplify scaling compared to AWS SQS or Redis, with built-in retries and monitoring, perfect for handling volatile crypto data.

Before We Start

You'll Need:

  • Node.js 18+ installed
  • Basic knowledge of TypeScript and React
  • A code editor (VS Code recommended)
  • A LunarCrush API key (We'll walk through signup below)

Two Ways to Experience This Tutorial:

  1. 👨‍💻 Build It Yourself - Follow along step-by-step with your own API key
  2. 🚀 Try the Live Demo - View the deployed version and clone the repo to explore the code

Quick Setup:

# Create a new Next.js project
npx create-next-app@latest social-sentiment-processor --typescript --tailwind --eslint
cd social-sentiment-processor

# Install dependencies
npm install inngest @inngest/next @supabase/supabase-js @radix-ui/react-alert-dialog lucide-react
Enter fullscreen mode Exit fullscreen mode

🚨 Common Issue: If you see authentication errors, double-check your API key format and that your subscription is active

Account Setup Guide

Sign Up For A LunarCrush Account

To access the LunarCrush API, you'll need to sign up for a paid plan:

  1. Visit LunarCrush Signup
  2. Enter your email address and click "Continue"
  3. Check your email for a verification code and enter it
  4. Complete the onboarding steps:
    • Select your favorite categories (or keep the defaults)
    • Create your profile (add a photo and nickname if desired)
    • Important: Select a subscription plan (you'll need it to generate an API key)

LunarCrush Signup Process

Available API Plans

LunarCrush Pricing Plans

Plan Price/Month Rate Limits Best For
Individual $24-30 10 requests/min, 2K/day Personal projects, learning
Builder $240-300 100 requests/min, 20K/day Professional apps, small businesses
Enterprise $1000+ Custom limits Large-scale applications

Generate Your API Key

Once you've subscribed, navigate to https://www.lunarcrush.com/developers/api/authentication and generate an API key.

API Key Generation

Save this API key - you'll add it to your environment variables later.

Additional Account Setup

Set Up Inngest Account

Inngest handles our background job processing:

  1. Sign up: Visit inngest.com and click "Sign up"
  2. Choose authentication: Sign up with GitHub, Google, or email
  3. Create your first app:
    • App name: "social-sentiment-processor"
    • Environment: "development" (for now)
  4. Get your keys: In the Inngest dashboard, go to Settings → Keys
    • Copy your Signing Key (starts with signkey_, create if necessary)
    • Copy your Event Key (starts with key_, create if necessary)

Set Up Supabase Database

Supabase provides our PostgreSQL database:

  1. Sign up: Visit supabase.com and click "Start your project"
  2. Create organization: Choose a name for your organization
  3. Create project:

    • Project name: "social-sentiment-processor"
    • Database password: Generate a strong password (save this!)
    • Region: Choose closest to your location
    • Pricing plan: Free tier is perfect for this tutorial
  4. Get your project credentials:

    • Go to Project Overview → Project API
    • Copy your Project URL (looks like https://xxx.supabase.co)
    • Copy your Anon public key (starts with eyJ...)

Set Up Discord Webhook (Optional)

For alert notifications:

  1. Create Discord server (or use existing one)
  2. Create a channel for alerts (e.g., #crypto-alerts)
  3. Create webhook:
    • Right-click the channel → Edit Channel
    • Go to Integrations → Webhooks
    • Click "New Webhook"
    • Name it "Crypto Sentiment Alerts"
    • Copy the webhook URL

Environment Setup

Create .env.local:

LUNARCRUSH_API_KEY=your_api_key_here
INNGEST_SIGNING_KEY=signkey_your_key_here
INNGEST_EVENT_KEY=key_your_event_key_here
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key_here
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/your_webhook_url
Enter fullscreen mode Exit fullscreen mode

Database Setup

In Supabase SQL Editor, run:

-- Sentiment history table
CREATE TABLE sentiment_history (
  id SERIAL PRIMARY KEY,
  symbol VARCHAR(20) NOT NULL,
  sentiment DECIMAL(10,2) NOT NULL,
  price DECIMAL(20,8) NOT NULL,
  interactions_24h BIGINT,
  percent_change_24h DECIMAL(10,4),
  galaxy_score DECIMAL(10,2),
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Processing jobs table
CREATE TABLE processing_jobs (
  id SERIAL PRIMARY KEY,
  status VARCHAR(20) NOT NULL DEFAULT 'pending',
  coins_processed INTEGER DEFAULT 0,
  alerts_generated INTEGER DEFAULT 0,
  duration_ms INTEGER,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  completed_at TIMESTAMP WITH TIME ZONE
);

-- Indexes for performance
CREATE INDEX idx_sentiment_symbol_time ON sentiment_history(symbol, created_at DESC);
CREATE INDEX idx_jobs_status ON processing_jobs(status, created_at DESC);
Enter fullscreen mode Exit fullscreen mode

Code Implementation

TypeScript Types

// src/types/lunarcrush.ts

export interface CoinData {
    id: number;
    symbol: string;
    name: string;
    price: number;
    sentiment: number;
    interactions_24h: number;
    social_volume_24h: number;
    social_dominance: number;
    percent_change_24h: number;
    galaxy_score: number;
    alt_rank: number;
    market_cap: number;
    last_updated_price: number;
    topic: string;
    logo: string;
}

export interface LunarCrushResponse {
    data: CoinData[];
}

export interface SentimentAlert {
    id: string;
    symbol: string;
    name: string;
    sentiment: number;
    previousSentiment?: number;
    changeType: 'spike' | 'drop' | 'normal';
    timestamp: number;
    message: string;
    price: number;
    percentChange24h: number;
}

export interface InngestEventData {
    timestamp: number;
    checkType: 'scheduled' | 'manual';
    coins?: string[]; // Optional: specific coins to check
}

export interface ProcessingResult {
    success: boolean;
    alertsGenerated: number;
    coinsProcessed: number;
    errors?: string[];
    duration: number;
}

export interface SingleCoinResponse {
  config: {
    id: string;
    name: string;
    symbol: string;
    topic: string;
    generated: number;
  };
  data: {
    id: number;
    name: string;
    symbol: string;
    price: number;
    price_btc: number;
    market_cap: number;
    percent_change_24h: number;
    percent_change_7d: number;
    percent_change_30d: number;
    volume_24h: number;
    max_supply: number | null;
    circulating_supply: number;
    close: number;
    galaxy_score: number;
    alt_rank: number;
    volatility: number;
    market_cap_rank: number;
  };
}
Enter fullscreen mode Exit fullscreen mode

API Client

// src/services/lunarcrush.ts
import { LunarCrushResponse, CoinData, SingleCoinResponse } from '@/types/lunarcrush';

const BASE_URL = 'https://lunarcrush.com/api4/public';


const getApiKey = () => {
  const apiKey = process.env.LUNARCRUSH_API_KEY;
  if (!apiKey) {
    throw new Error('LUNARCRUSH_API_KEY environment variable is required');
  }
  return apiKey;
};

const makeRequest = async <T>(endpoint: string): Promise<T> => {
  const url = `${BASE_URL}${endpoint}`;

  try {
    const response = await fetch(url, {
      headers: {
        'Authorization': `Bearer ${getApiKey()}`,
        'Content-Type': 'application/json',
      },
      signal: AbortSignal.timeout(10000), // 10 second timeout
    });

    if (!response.ok) {
      if (response.status === 401) {
        throw new Error('Invalid API key - check your LunarCrush credentials');
      }
      if (response.status === 429) {
        throw new Error('Rate limit exceeded - upgrade your plan or try again later');
      }
      if (response.status >= 500) {
        throw new Error('LunarCrush API is temporarily unavailable');
      }

      throw new Error(`API Error: ${response.status} ${response.statusText}`);
    }

    return await response.json();
  } catch (error) {
    if (error instanceof Error) {
      console.error('LunarCrush API Error:', error.message);
      throw error;
    }
    throw new Error('Unknown error occurred while fetching data');
  }
};

// Convert single coin response to match our CoinData interface
const normalizeSingleCoinData = (response: SingleCoinResponse): CoinData => {
  return {
    id: response.data.id,
    symbol: response.data.symbol,
    name: response.data.name,
    price: response.data.price,
    sentiment: 0, // Not available in single coin endpoint
    interactions_24h: 0, // Not available in single coin endpoint
    social_volume_24h: 0, // Not available in single coin endpoint
    social_dominance: 0, // Not available in single coin endpoint
    percent_change_24h: response.data.percent_change_24h,
    galaxy_score: response.data.galaxy_score,
    alt_rank: response.data.alt_rank,
    market_cap: response.data.market_cap,
    last_updated_price: Date.now() / 1000,
    topic: response.config.topic,
    logo: `https://cdn.lunarcrush.com/${response.data.symbol.toLowerCase()}.png`,
  };
};

// Fetch all coins (efficient for batch processing)
export const getAllCoins = async (): Promise<CoinData[]> => {
  const response = await makeRequest<LunarCrushResponse>('/coins/list/v1');
  return response.data;
};

// Fetch single coin by symbol (efficient for individual lookups)
export const getCoinBySymbol = async (symbol: string): Promise<CoinData | null> => {
  try {
    const response = await makeRequest<SingleCoinResponse>(`/coins/${symbol.toUpperCase()}/v1`);
    return normalizeSingleCoinData(response);
  } catch (error) {
    // Return null if coin not found, otherwise re-throw
    if (error instanceof Error && error.message.includes('404')) {
      return null;
    }
    throw error;
  }
};

// Fetch multiple coins by symbols (uses individual endpoint for efficiency)
export const getCoinsBySymbols = async (symbols: string[]): Promise<CoinData[]> => {
  const coinPromises = symbols.map(symbol => getCoinBySymbol(symbol));
  const results = await Promise.allSettled(coinPromises);

  return results
    .filter((result): result is PromiseFulfilledResult<CoinData> =>
      result.status === 'fulfilled' && result.value !== null
    )
    .map(result => result.value);
};

// For sentiment analysis, we need the full list (has sentiment data)
export const getCoinsWithSentiment = async (symbols?: string[]): Promise<CoinData[]> => {
  const allCoins = await getAllCoins();

  let filteredCoins = allCoins;

  if (symbols && symbols.length > 0) {
    const upperSymbols = symbols.map(s => s.toUpperCase());
    filteredCoins = allCoins.filter(coin =>
      upperSymbols.includes(coin.symbol.toUpperCase())
    );
  }

  // Deduplicate by symbol - keep the one with highest market cap
  const deduplicatedCoins = Object.values(
    filteredCoins.reduce((acc, coin) => {
      const symbol = coin.symbol.toUpperCase();

      // If we haven't seen this symbol, or this coin has higher market cap
      if (!acc[symbol] || coin.market_cap > acc[symbol].market_cap) {
        acc[symbol] = coin;
      }

      return acc;
    }, {} as Record<string, CoinData>)
  );

  return deduplicatedCoins;
};

export const getTopCoins = async (limit: number = 100): Promise<CoinData[]> => {
  const allCoins = await getAllCoins();
  return allCoins
    .sort((a, b) => b.market_cap - a.market_cap)
    .slice(0, limit);
};

export const detectSentimentChange = (current: number, previous?: number): 'spike' | 'drop' | 'normal' => {
  if (!previous) return 'normal';

  const change = current - previous;
  const percentChange = Math.abs(change) / previous;

  // Significant change if >20% difference and crosses important thresholds
  if (percentChange > 0.2) {
    if (change > 0 && current >= 80) return 'spike';
    if (change < 0 && current <= 20) return 'drop';
  }

  return 'normal';
};
Enter fullscreen mode Exit fullscreen mode

Database Helpers

// src/lib/supabase.ts

import { createClient } from '@supabase/supabase-js';
import { SentimentHistory, ProcessingJob } from '@/types/database';

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;

if (!supabaseUrl || !supabaseKey) {
  throw new Error('Missing Supabase environment variables');
}

export const supabase = createClient(supabaseUrl, supabaseKey);

// Database helper functions
export const saveSentimentHistory = async (data: Omit<SentimentHistory, 'id' | 'created_at'>) => {
  const { error } = await supabase
    .from('sentiment_history')
    .insert(data);

  if (error) throw error;
};

export const createProcessingJob = async (): Promise<string> => {
  const { data, error } = await supabase
    .from('processing_jobs')
    .insert({ status: 'pending' })
    .select('id')
    .single();

  if (error) throw error;
  return data.id;
};

export const updateProcessingJob = async (
  id: string,
  updates: Partial<ProcessingJob>
) => {
  const { error } = await supabase
    .from('processing_jobs')
    .update(updates)
    .eq('id', id);

  if (error) throw error;
};

export const getRecentSentiment = async (symbol: string, hours: number = 24) => {
  const { data, error } = await supabase
    .from('sentiment_history')
    .select('*')
    .eq('symbol', symbol)
    .gte('created_at', new Date(Date.now() - hours * 60 * 60 * 1000).toISOString())
    .order('created_at', { ascending: false });

  if (error) throw error;
  return data;
};
Enter fullscreen mode Exit fullscreen mode

Inngest Background Function

// src/lib/inngest.ts
import { Inngest } from 'inngest';
export const inngest = new Inngest({ id: 'social-sentiment-processor' });

// src/functions/processSentiment.ts

import { inngest } from '@/lib/inngest';
import { getCoinsWithSentiment } from '@/services/lunarcrush';
import {
    saveSentimentHistory,
    createProcessingJob,
    updateProcessingJob,
    getRecentSentiment,
} from '@/lib/supabase';
import { MONITORED_COINS } from '@/lib/constants';
import { SentimentAlert } from '@/types/lunarcrush';

export const processSentimentData = inngest.createFunction(
    { id: 'process-sentiment-data' },
    { event: 'sentiment/process' },
    async ({ event, step }) => {
        const startTime = Date.now();

        // Step 1: Create processing job record
        const jobId = await step.run('create-job', async () => {
            return await createProcessingJob();
        });

        // Step 2: Update job status to processing
        await step.run('start-processing', async () => {
            await updateProcessingJob(jobId, {
                status: 'processing',
            });
        });

        // Step 3: Fetch current sentiment data
        const currentData = await step.run('fetch-sentiment-data', async () => {
            const coins = event.data.coins || MONITORED_COINS;
            const data = await getCoinsWithSentiment(coins as string[]);
            return data;
        });

        // Step 4: Process each coin and detect changes
        const alerts = await step.run('analyze-sentiment-changes', async () => {
            const alertPromises = currentData.map(async (coin) => {
                try {
                    // Get recent sentiment history for comparison
                    const recentHistory = await getRecentSentiment(coin.symbol, 24);
                    const previousSentiment =
                        recentHistory.length > 0 ? recentHistory[0].sentiment : undefined;

                    // Detect sentiment changes
                    let changeType = 'normal';

                    if (previousSentiment) {
                        // Compare with historical data
                        const change = coin.sentiment - previousSentiment;
                        const percentChange = Math.abs(change) / previousSentiment;

                        // 10% threshold for production
                        if (percentChange > 0.1) {
                            if (change > 0 && coin.sentiment >= 70) {
                                changeType = 'spike';
                            }
                            if (change < 0 && coin.sentiment <= 30) {
                                changeType = 'drop';
                            }
                        }
                    } else {
                        // No historical data - alert on extreme values
                        if (coin.sentiment >= 80) {
                            changeType = 'spike';
                        } else if (coin.sentiment <= 20) {
                            changeType = 'drop';
                        }
                    }

                    // Save current data to history
                    const sentimentData = {
                        symbol: coin.symbol,
                        sentiment: coin.sentiment,
                        price: coin.price,
                        interactions_24h: coin.interactions_24h,
                        percent_change_24h: coin.percent_change_24h,
                        galaxy_score: coin.galaxy_score,
                    };

                    await saveSentimentHistory(sentimentData);

                    // Generate alert if significant change
                    if (changeType !== 'normal') {
                        const alert: SentimentAlert = {
                            id: `${coin.symbol}-${Date.now()}`,
                            symbol: coin.symbol,
                            name: coin.name,
                            sentiment: coin.sentiment,
                            previousSentiment,
                            changeType,
                            timestamp: Date.now(),
                            message: generateAlertMessage(
                                coin.symbol,
                                coin.sentiment,
                                changeType,
                                previousSentiment
                            ),
                            price: coin.price,
                            percentChange24h: coin.percent_change_24h,
                        };

                        return alert;
                    }

                    return null;
                } catch (error) {
                    console.error(`Error processing ${coin.symbol}:`, error);
                    return null;
                }
            });

            const results = await Promise.allSettled(alertPromises);
            const successfulAlerts = results
                .filter(
                    (result): result is PromiseFulfilledResult<SentimentAlert> =>
                        result.status === 'fulfilled' && result.value !== null
                )
                .map((result) => result.value);

            return successfulAlerts;
        });

        // Step 5: Send alerts if any were generated
        await step.run('send-alerts', async () => {
            if (alerts.length > 0 && process.env.DISCORD_WEBHOOK_URL) {
                await sendDiscordAlerts(alerts);
            }
        });

        // Step 6: Complete the job
        await step.run('complete-job', async () => {
            const duration = Date.now() - startTime;
            await updateProcessingJob(jobId, {
                status: 'completed',
                coins_processed: currentData.length,
                alerts_generated: alerts.length,
                duration_ms: duration,
                completed_at: new Date().toISOString(),
            });
        });

        return {
            success: true,
            coinsProcessed: currentData.length,
            alertsGenerated: alerts.length,
            duration: Date.now() - startTime,
            alerts: alerts.map((alert) => ({
                symbol: alert.symbol,
                sentiment: alert.sentiment,
                changeType: alert.changeType,
                message: alert.message,
            })),
        };
    }
);

// Generate alert messages
function generateAlertMessage(
    symbol: string,
    sentiment: number,
    changeType: 'spike' | 'drop',
    previousSentiment?: number
): string {
    const direction = changeType === 'spike' ? '📈' : '📉';

    if (previousSentiment) {
        const change = sentiment - previousSentiment;
        const percentChange = (change / previousSentiment) * 100;
        return `${direction} ${symbol} sentiment ${changeType}! Now at ${sentiment}/100 (${
            change > 0 ? '+' : ''
        }${change.toFixed(1)} from ${previousSentiment}, ${
            percentChange > 0 ? '+' : ''
        }${percentChange.toFixed(1)}%)`;
    } else {
        return `${direction} ${symbol} has ${
            changeType === 'spike' ? 'high' : 'low'
        } sentiment at ${sentiment}/100 (first analysis)`;
    }
}

// Send Discord alerts
async function sendDiscordAlerts(alerts: SentimentAlert[]) {
    const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
    if (!webhookUrl) return;

    const embed = {
        title: '🚨 Crypto Sentiment Alerts',
        description: `${alerts.length} significant sentiment ${
            alerts.length === 1 ? 'change' : 'changes'
        } detected`,
        color: alerts.some((a) => a.changeType === 'drop') ? 0xff0000 : 0x00ff00,
        fields: alerts.slice(0, 10).map((alert) => ({
            name: `${alert.symbol} ${alert.changeType === 'spike' ? '📈' : '📉'}`,
            value: alert.message,
            inline: false,
        })),
        timestamp: new Date().toISOString(),
        footer: {
            text: 'Social Sentiment Processor • Powered by LunarCrush',
        },
    };

    try {
        const response = await fetch(webhookUrl, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({ embeds: [embed] }),
        });

        if (!response.ok) {
            console.error(
                'Discord webhook failed:',
                response.status,
                response.statusText
            );
        }
    } catch (error) {
        console.error('Failed to send Discord alert:', error);
    }
}
Enter fullscreen mode Exit fullscreen mode

API Routes & Dashboard

Inngest API Route:

// app/api/inngest/route.ts

import { serve } from 'inngest/next';
import { inngest } from '@/lib/inngest';
import { processSentimentData } from '@/functions/processSentiment';

export const { GET, POST, PUT } = serve({
    client: inngest,
    functions: [processSentimentData],
});

Enter fullscreen mode Exit fullscreen mode

Trigger Route:

// app/api/trigger/route.ts

import { inngest } from '@/lib/inngest';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
    try {
        console.log('🚀 Trigger API called at:', new Date().toISOString());

        const body = await request.json();
        const { coins } = body;

        console.log('📋 Requested coins:', coins);

        // Send event to Inngest
        const eventId = await inngest.send({
            name: 'sentiment/process',
            data: {
                timestamp: Date.now(),
                checkType: 'manual',
                coins: coins || undefined,
            },
        });

        console.log('✅ Event sent to Inngest with ID:', eventId);

        return NextResponse.json({
            success: true,
            eventId,
            message: 'Sentiment processing job queued successfully',
        });
    } catch (error) {
        console.error('❌ Failed to trigger sentiment processing:', error);
        return NextResponse.json(
            {
                success: false,
                error: 'Failed to queue processing job',
            },
            { status: 500 }
        );
    }
}

export async function GET() {
    return NextResponse.json({
        status: 'Sentiment Processing API',
        endpoints: {
            POST: 'Queue a new sentiment processing job',
        },
    });
}
Enter fullscreen mode Exit fullscreen mode

Simple Dashboard:

// app/dashboard/page.tsx

'use client';

import { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabase';
import { ProcessingJob, SentimentHistory } from '@/types/database';
import { MONITORED_COINS } from '@/lib/constants';

export default function Dashboard() {
    const [jobs, setJobs] = useState<ProcessingJob[]>([]);
    const [recentSentiment, setRecentSentiment] = useState<SentimentHistory[]>(
        []
    );
    const [isProcessing, setIsProcessing] = useState(false);
    const [progress, setProgress] = useState(0);
    const [currentStep, setCurrentStep] = useState('');
    const [selectedCoins, setSelectedCoins] = useState<string[]>(
        MONITORED_COINS as string[]
    );
    const [lastAnalysisResults, setLastAnalysisResults] = useState<{
        processed: string[];
        found: string[];
        missing: string[];
    }>({ processed: [], found: [], missing: [] });

    // Processing steps with realistic timing
    const processingSteps = [
        { message: 'Queuing background job with Inngest...', duration: 1000 },
        {
            message: 'Fetching sentiment data from LunarCrush API...',
            duration: 2500,
        },
        { message: 'Analyzing sentiment changes and trends...', duration: 2000 },
        { message: 'Saving results to database...', duration: 1500 },
        { message: 'Finalizing and updating dashboard...', duration: 1000 },
    ];

    useEffect(() => {
        fetchJobs();
        fetchRecentSentiment();
    }, []);

    const fetchJobs = async () => {
        const { data } = await supabase
            .from('processing_jobs')
            .select('*')
            .order('created_at', { ascending: false })
            .limit(10);

        if (data) setJobs(data);
    };

    const fetchRecentSentiment = async () => {
        const { data } = await supabase
            .from('sentiment_history')
            .select('*')
            .order('created_at', { ascending: false })
            .limit(50);

        if (data) {
            setRecentSentiment(data);

            // Analyze ONLY the most recent analysis run
            if (data.length > 0) {
                // Get the most recent timestamp (latest analysis)
                const latestTimestamp = data[0].created_at;
                const cutoffTime = new Date(
                    new Date(latestTimestamp).getTime() - 60000
                ); // 1 minute window

                // Get coins from the most recent analysis run only
                const latestAnalysisCoins = data
                    .filter((entry) => new Date(entry.created_at) >= cutoffTime)
                    .map((entry) => entry.symbol);

                const foundCoins = [...new Set(latestAnalysisCoins)];
                const processed =
                    selectedCoins.length > 0
                        ? selectedCoins
                        : (MONITORED_COINS as string[]);
                const missing = processed.filter((coin) => !foundCoins.includes(coin));

                setLastAnalysisResults({
                    processed,
                    found: foundCoins,
                    missing,
                });
            }
        }
    };

    const clearSentimentHistory = async () => {
        try {
            await supabase
                .from('sentiment_history')
                .delete()
                .neq('id', '00000000-0000-0000-0000-000000000000');
            setRecentSentiment([]);
            setLastAnalysisResults({ processed: [], found: [], missing: [] });
        } catch (error) {
            console.error('Failed to clear history:', error);
        }
    };

    const triggerProcessing = async () => {
        setIsProcessing(true);
        setProgress(0);
        setCurrentStep('');

        try {
            const response = await fetch('/api/trigger', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ coins: selectedCoins }),
            });

            if (response.ok) {
                // Animate through processing steps
                let stepIndex = 0;
                let totalElapsed = 0;
                const totalDuration = processingSteps.reduce(
                    (sum, step) => sum + step.duration,
                    0
                );

                const runStep = () => {
                    if (stepIndex < processingSteps.length) {
                        const step = processingSteps[stepIndex];
                        setCurrentStep(step.message);

                        const stepStart = Date.now();
                        const animateStep = () => {
                            const stepElapsed = Date.now() - stepStart;
                            const stepProgress = Math.min(stepElapsed / step.duration, 1);
                            const overallProgress =
                                ((totalElapsed + stepElapsed) / totalDuration) * 100;

                            setProgress(Math.min(overallProgress, 100));

                            if (stepProgress < 1) {
                                requestAnimationFrame(animateStep);
                            } else {
                                totalElapsed += step.duration;
                                stepIndex++;
                                setTimeout(runStep, 100);
                            }
                        };

                        requestAnimationFrame(animateStep);
                    } else {
                        setCurrentStep('Complete! Refreshing data...');
                        setProgress(100);
                        setTimeout(() => {
                            fetchJobs();
                            fetchRecentSentiment();
                            setIsProcessing(false);
                            setProgress(0);
                            setCurrentStep('');
                        }, 500);
                    }
                };

                runStep();
            } else {
                setIsProcessing(false);
                setProgress(0);
                setCurrentStep('');
            }
        } catch (error) {
            console.error('Failed to trigger processing:', error);
            setIsProcessing(false);
            setProgress(0);
            setCurrentStep('');
        }
    };

    return (
        <div className='min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900'>
            {/* Header */}
            <div className='bg-gray-800 shadow-lg border-b border-gray-700'>
                <div className='max-w-7xl mx-auto px-6 py-4'>
                    <div className='flex items-center justify-between'>
                        <div className='flex items-center space-x-3'>
                            <div className='w-10 h-10 bg-gradient-to-r from-blue-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg'>
                                <span className='text-white text-xl'>🚀</span>
                            </div>
                            <div>
                                <h1 className='text-2xl font-bold text-white'>
                                    Social Sentiment Processor
                                </h1>
                                <p className='text-sm text-gray-300'>
                                    Track crypto sentiment changes over time with background
                                    processing
                                </p>
                            </div>
                        </div>
                        <div className='flex items-center space-x-2 text-sm text-gray-300'>
                            <div className='w-2 h-2 bg-green-400 rounded-full animate-pulse'></div>
                            <span>Live</span>
                        </div>
                    </div>
                </div>
            </div>

            <div className='max-w-7xl mx-auto px-6 py-8'>
                {/* Control Panel */}
                <div className='bg-gray-800 rounded-2xl shadow-2xl border border-gray-700 p-8 mb-8'>
                    <div className='flex items-center justify-between mb-6'>
                        <div>
                            <h2 className='text-xl font-semibold text-white'>
                                Sentiment Change Detection
                            </h2>
                            <p className='text-gray-300 mt-1'>
                                Fetch current sentiment scores and compare them to your previous
                                analysis
                            </p>
                            <div className='mt-3 text-sm text-blue-300 bg-blue-900/20 rounded-lg p-4 border border-blue-700/30'>
                                <div className='font-medium mb-2'>🎯 How it works:</div>
                                <ul className='space-y-1 text-xs'>
                                    <li>
                                         <span className='font-medium'>First Run:</span> Gets
                                        current sentiment scores and saves them as baseline
                                    </li>
                                    <li>
                                         <span className='font-medium'>Future Runs:</span> Compares
                                        new scores vs. your saved history to detect spikes/drops
                                    </li>
                                    <li>
                                         <span className='font-medium'>Alerts:</span> Flags
                                        significant changes (20%+ with extreme scores)
                                    </li>
                                </ul>
                            </div>
                        </div>
                        <div className='flex flex-col items-end space-y-2'>
                            <div className='px-4 py-2 bg-blue-900/50 rounded-lg border border-blue-700/50'>
                                <span className='text-sm font-medium text-blue-300'>
                                    Powered by Inngest
                                </span>
                            </div>
                            <div className='px-4 py-2 bg-green-900/50 rounded-lg border border-green-700/50'>
                                <span className='text-sm font-medium text-green-300'>
                                    Data by LunarCrush
                                </span>
                            </div>
                        </div>
                    </div>

                    {/* Coin Selection */}
                    <div className='mb-8'>
                        <label className='block text-sm font-semibold text-gray-300 mb-4'>
                            Select Cryptocurrencies to Monitor:
                        </label>
                        <div className='grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-3'>
                            {MONITORED_COINS.map((coin) => (
                                <label key={coin} className='cursor-pointer group'>
                                    <input
                                        type='checkbox'
                                        checked={selectedCoins.includes(coin)}
                                        onChange={(e) => {
                                            if (e.target.checked) {
                                                setSelectedCoins([...selectedCoins, coin]);
                                            } else {
                                                setSelectedCoins(
                                                    selectedCoins.filter((c) => c !== coin)
                                                );
                                            }
                                        }}
                                        className='sr-only'
                                    />
                                    <div
                                        className={`px-4 py-3 rounded-xl border-2 transition-all duration-200 text-center relative ${
                                            selectedCoins.includes(coin)
                                                ? 'border-blue-400 bg-blue-900/50 text-blue-200 shadow-lg shadow-blue-500/25 scale-105'
                                                : 'border-gray-600 bg-gray-700 text-gray-300 hover:border-gray-500 hover:bg-gray-600 hover:scale-102'
                                        }`}>
                                        {selectedCoins.includes(coin) && (
                                            <div className='absolute -top-1 -right-1 w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center'>
                                                <span className='text-white text-xs'></span>
                                            </div>
                                        )}
                                        <span className='font-semibold text-sm'>{coin}</span>
                                    </div>
                                </label>
                            ))}
                        </div>
                        <p className='text-sm text-gray-400 mt-3'>
                            {selectedCoins.length} of {MONITORED_COINS.length}{' '}
                            cryptocurrencies selected
                        </p>
                    </div>

                    {/* Action Button & Progress */}
                    <div className='space-y-6'>
                        <button
                            onClick={triggerProcessing}
                            disabled={isProcessing || selectedCoins.length === 0}
                            className={`w-full py-4 px-6 rounded-xl font-semibold text-lg transition-all duration-200 ${
                                isProcessing
                                    ? 'bg-gray-600 text-gray-400 cursor-not-allowed'
                                    : selectedCoins.length === 0
                                    ? 'bg-gray-600 text-gray-400 cursor-not-allowed'
                                    : 'bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white shadow-lg hover:shadow-xl transform hover:-translate-y-0.5'
                            }`}>
                            {isProcessing ? (
                                <div className='flex items-center justify-center space-x-3'>
                                    <div className='w-5 h-5 border-2 border-gray-400 border-t-transparent rounded-full animate-spin'></div>
                                    <span>Processing Sentiment Changes...</span>
                                </div>
                            ) : (
                                `Check Sentiment Changes for ${selectedCoins.length} Cryptocurrencies`
                            )}
                        </button>

                        {/* Enhanced Progress Section */}
                        {isProcessing && (
                            <div className='bg-gradient-to-r from-blue-900/30 to-purple-900/30 rounded-2xl p-6 border border-blue-700/30'>
                                <div className='flex items-center justify-between mb-4'>
                                    <div className='flex items-center space-x-3'>
                                        <div className='w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center'>
                                            <div className='w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin'></div>
                                        </div>
                                        <div>
                                            <h3 className='font-semibold text-blue-200'>
                                                Processing in Progress
                                            </h3>
                                            <p className='text-sm text-blue-300'>{currentStep}</p>
                                        </div>
                                    </div>
                                    <div className='text-right'>
                                        <div className='text-2xl font-bold text-blue-200'>
                                            {Math.round(progress)}%
                                        </div>
                                        <div className='text-xs text-blue-400'>Complete</div>
                                    </div>
                                </div>

                                {/* Actual Progress Bar */}
                                <div className='relative'>
                                    <div className='w-full bg-blue-900/50 rounded-full h-4 shadow-inner'>
                                        <div
                                            className='bg-gradient-to-r from-blue-500 via-blue-400 to-purple-500 h-4 rounded-full shadow-lg transition-all duration-300 ease-out relative overflow-hidden'
                                            style={{ width: `${progress}%` }}>
                                            <div className='absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent opacity-30 animate-pulse'></div>
                                        </div>
                                    </div>
                                </div>

                                <div className='flex items-center justify-between mt-4 text-sm'>
                                    <span className='text-blue-300'>
                                         Comparing {selectedCoins.length} cryptocurrencies to
                                        history
                                    </span>
                                    <span className='text-blue-400'>
                                        ~{Math.ceil((100 - progress) * 0.08)} seconds remaining
                                    </span>
                                </div>
                            </div>
                        )}
                    </div>
                </div>

                {/* Results Grid */}
                <div className='grid grid-cols-1 xl:grid-cols-2 gap-8'>
                    {/* Recent Jobs */}
                    <div className='bg-gray-800 rounded-2xl shadow-xl border border-gray-700 overflow-hidden'>
                        <div className='bg-gradient-to-r from-green-900/50 to-emerald-900/50 px-6 py-4 border-b border-gray-700'>
                            <h2 className='text-lg font-semibold text-white flex items-center'>
                                <div className='w-2 h-2 bg-green-400 rounded-full mr-3'></div>
                                Analysis History
                            </h2>
                            <p className='text-sm text-gray-300 mt-1'>
                                Track of your sentiment analysis requests
                            </p>
                        </div>

                        <div className='p-6'>
                            {jobs.length === 0 ? (
                                <div className='text-center py-12'>
                                    <div className='w-16 h-16 bg-gray-700 rounded-full flex items-center justify-center mx-auto mb-4'>
                                        <span className='text-gray-400 text-2xl'>📊</span>
                                    </div>
                                    <p className='text-gray-400'>
                                        No analysis history yet. Start your first sentiment
                                        analysis!
                                    </p>
                                </div>
                            ) : (
                                <div className='space-y-4'>
                                    {jobs.map((job, index) => (
                                        <div
                                            key={job.id}
                                            className={`p-4 rounded-xl border-l-4 transition-all duration-200 hover:shadow-lg ${
                                                job.status === 'completed'
                                                    ? 'border-green-400 bg-green-900/20 hover:bg-green-900/30'
                                                    : job.status === 'failed'
                                                    ? 'border-red-400 bg-red-900/20 hover:bg-red-900/30'
                                                    : job.status === 'processing'
                                                    ? 'border-blue-400 bg-blue-900/20 hover:bg-blue-900/30'
                                                    : 'border-gray-600 bg-gray-700/20 hover:bg-gray-700/30'
                                            }`}>
                                            <div className='flex justify-between items-start'>
                                                <div>
                                                    <div className='flex items-center space-x-2 mb-2'>
                                                        <span
                                                            className={`w-2 h-2 rounded-full ${
                                                                job.status === 'completed'
                                                                    ? 'bg-green-400'
                                                                    : job.status === 'failed'
                                                                    ? 'bg-red-400'
                                                                    : job.status === 'processing'
                                                                    ? 'bg-blue-400 animate-pulse'
                                                                    : 'bg-gray-500'
                                                            }`}></span>
                                                        <span className='font-semibold capitalize text-sm text-gray-200'>
                                                            {job.status}
                                                        </span>
                                                    </div>
                                                    <p className='text-sm text-gray-300 mb-1'>
                                                        Analyzed {job.coins_processed} coins  Generated{' '}
                                                        {job.alerts_generated} change alerts
                                                    </p>
                                                    {job.duration_ms && (
                                                        <p className='text-xs text-gray-400'>
                                                            Completed in {(job.duration_ms / 1000).toFixed(1)}
                                                            s
                                                        </p>
                                                    )}
                                                </div>
                                                <div className='text-right'>
                                                    <p className='text-xs text-gray-400 mb-1'>
                                                        {new Date(job.created_at).toLocaleTimeString()}
                                                    </p>
                                                    <span className='inline-block px-2 py-1 bg-gray-600 rounded-full text-xs text-gray-300'>
                                                        #{index + 1}
                                                    </span>
                                                </div>
                                            </div>
                                            {job.error_message && (
                                                <p className='text-sm text-red-300 mt-2 p-2 bg-red-900/30 rounded'>
                                                    {job.error_message}
                                                </p>
                                            )}
                                        </div>
                                    ))}
                                </div>
                            )}
                        </div>
                    </div>

                    {/* Sentiment Results */}
                    <div className='bg-gray-800 rounded-2xl shadow-xl border border-gray-700 overflow-hidden'>
                        <div className='bg-gradient-to-r from-purple-900/50 to-pink-900/50 px-6 py-4 border-b border-gray-700'>
                            <div className='flex items-center justify-between'>
                                <div className='flex items-center'>
                                    <div className='w-2 h-2 bg-purple-400 rounded-full mr-3'></div>
                                    <div>
                                        <h2 className='text-lg font-semibold text-white'>
                                            Sentiment Change Results
                                        </h2>
                                        <p className='text-sm text-gray-300 mt-1'>
                                            Current scores vs. your historical data
                                        </p>
                                    </div>
                                </div>
                                {recentSentiment.length > 0 && (
                                    <button
                                        onClick={() => {
                                            if (
                                                confirm(
                                                    'Clear all sentiment history? This will reset change detection.'
                                                )
                                            ) {
                                                clearSentimentHistory();
                                            }
                                        }}
                                        className='text-xs text-gray-400 hover:text-gray-300 px-3 py-1 border border-gray-600 rounded-lg hover:border-gray-500'>
                                        Clear History
                                    </button>
                                )}
                            </div>
                        </div>

                        <div className='p-6'>
                            {/* Analysis Summary */}
                            {lastAnalysisResults.processed.length > 0 && (
                                <div className='mb-6 p-4 bg-gray-700/30 rounded-lg border border-gray-600'>
                                    <h3 className='font-semibold text-gray-200 mb-2'>
                                        Latest Analysis Summary
                                    </h3>
                                    <div className='grid grid-cols-4 gap-4 text-center'>
                                        <div>
                                            <div className='text-blue-400 font-bold text-lg'>
                                                {lastAnalysisResults.processed.length}
                                            </div>
                                            <div className='text-xs text-gray-400'>Requested</div>
                                        </div>
                                        <div>
                                            <div className='text-green-400 font-bold text-lg'>
                                                {lastAnalysisResults.found.length}
                                            </div>
                                            <div className='text-xs text-gray-400'>Found</div>
                                        </div>
                                        <div>
                                            <div className='text-yellow-400 font-bold text-lg'>
                                                {lastAnalysisResults.missing.length}
                                            </div>
                                            <div className='text-xs text-gray-400'>No Data</div>
                                        </div>
                                        <div>
                                            <div className='text-purple-400 font-bold text-lg'>
                                                {(() => {
                                                    // Count significant changes in latest analysis
                                                    if (recentSentiment.length === 0) return 0;

                                                    // Get the most recent timestamp
                                                    const latestTimestamp = recentSentiment[0].created_at;
                                                    const cutoffTime = new Date(
                                                        new Date(latestTimestamp).getTime() - 60000
                                                    );

                                                    // Get coins from the most recent analysis
                                                    const latestEntries = recentSentiment.filter(
                                                        (entry) => new Date(entry.created_at) >= cutoffTime
                                                    );

                                                    return latestEntries.filter((entry) => {
                                                        const previousEntries = recentSentiment
                                                            .filter(
                                                                (prev) =>
                                                                    prev.symbol === entry.symbol &&
                                                                    prev.id !== entry.id &&
                                                                    new Date(prev.created_at) < cutoffTime
                                                            )
                                                            .sort(
                                                                (a, b) =>
                                                                    new Date(b.created_at).getTime() -
                                                                    new Date(a.created_at).getTime()
                                                            );

                                                        const previousSentiment =
                                                            previousEntries[0]?.sentiment;
                                                        if (!previousSentiment) return false;

                                                        const changeAmount =
                                                            entry.sentiment - previousSentiment;
                                                        const percentChange =
                                                            Math.abs(changeAmount) / previousSentiment;

                                                        if (percentChange > 0.2) {
                                                            if (changeAmount > 0 && entry.sentiment >= 80)
                                                                return true;
                                                            if (changeAmount < 0 && entry.sentiment <= 20)
                                                                return true;
                                                        }
                                                        return false;
                                                    }).length;
                                                })()}
                                            </div>
                                            <div className='text-xs text-gray-400'>Changes</div>
                                        </div>
                                    </div>
                                    {lastAnalysisResults.missing.length > 0 && (
                                        <div className='mt-3 text-xs text-yellow-300'>
                                            <span className='font-medium'>Note:</span>{' '}
                                            {lastAnalysisResults.missing.join(', ')} had no sentiment
                                            data available from LunarCrush API
                                        </div>
                                    )}
                                </div>
                            )}

                            {recentSentiment.length === 0 ? (
                                <div className='text-center py-12'>
                                    <div className='w-16 h-16 bg-gray-700 rounded-full flex items-center justify-center mx-auto mb-4'>
                                        <span className='text-gray-400 text-2xl'>💭</span>
                                    </div>
                                    <p className='text-gray-400 mb-2'>
                                        No baseline sentiment data yet
                                    </p>
                                    <p className='text-sm text-gray-500'>
                                        Run your first analysis to establish a baseline for change
                                        detection
                                    </p>
                                </div>
                            ) : (
                                <div className='space-y-4'>
                                    {/* Sort by recency - most recently analyzed first */}
                                    {(() => {
                                        // Get only the most recent entry for each coin
                                        const latestByCoin = recentSentiment.reduce(
                                            (acc, entry) => {
                                                if (
                                                    !acc[entry.symbol] ||
                                                    new Date(entry.created_at) >
                                                        new Date(acc[entry.symbol].created_at)
                                                ) {
                                                    acc[entry.symbol] = entry;
                                                }
                                                return acc;
                                            },
                                            {} as Record<string, SentimentHistory>
                                        );

                                        return Object.values(latestByCoin)
                                            .sort(
                                                (a, b) =>
                                                    new Date(b.created_at).getTime() -
                                                    new Date(a.created_at).getTime()
                                            ) // Sort by most recent first
                                            .map((entry) => {
                                                // Get previous sentiment for change detection
                                                const previousEntries = recentSentiment
                                                    .filter(
                                                        (prev) =>
                                                            prev.symbol === entry.symbol &&
                                                            prev.id !== entry.id
                                                    )
                                                    .sort(
                                                        (a, b) =>
                                                            new Date(b.created_at).getTime() -
                                                            new Date(a.created_at).getTime()
                                                    );

                                                const previousSentiment = previousEntries[0]?.sentiment;

                                                // Calculate change
                                                let changeType = 'normal';
                                                let changeAmount = 0;
                                                let changeMessage = '';

                                                if (previousSentiment) {
                                                    changeAmount = entry.sentiment - previousSentiment;
                                                    const percentChange =
                                                        Math.abs(changeAmount) / previousSentiment;

                                                    if (percentChange > 0.2) {
                                                        // 20% threshold
                                                        if (changeAmount > 0 && entry.sentiment >= 80)
                                                            changeType = 'spike';
                                                        if (changeAmount < 0 && entry.sentiment <= 20)
                                                            changeType = 'drop';
                                                    }

                                                    if (changeAmount === 0) {
                                                        changeMessage = 'No change from previous analysis';
                                                    } else {
                                                        changeMessage = `${
                                                            changeAmount > 0 ? '+' : ''
                                                        }${changeAmount} from previous (${previousSentiment})`;
                                                    }
                                                } else {
                                                    changeMessage =
                                                        'First analysis - no history to compare';
                                                }

                                                return (
                                                    <div
                                                        key={entry.id}
                                                        className='p-4 border border-gray-600 rounded-xl hover:shadow-lg transition-all duration-200 hover:border-gray-500 bg-gray-700/20'>
                                                        <div className='flex justify-between items-center'>
                                                            <div className='flex items-center space-x-4'>
                                                                <div className='w-10 h-10 bg-gradient-to-r from-gray-500 to-gray-600 rounded-full flex items-center justify-center'>
                                                                    <span className='text-white font-bold text-sm'>
                                                                        {entry.symbol.slice(0, 2)}
                                                                    </span>
                                                                </div>
                                                                <div>
                                                                    <div className='flex items-center space-x-2'>
                                                                        <span className='font-bold text-lg text-white'>
                                                                            {entry.symbol}
                                                                        </span>
                                                                        {changeType !== 'normal' && (
                                                                            <span
                                                                                className={`px-2 py-1 rounded-full text-xs font-bold ${
                                                                                    changeType === 'spike'
                                                                                        ? 'bg-green-900/50 text-green-300 border border-green-600'
                                                                                        : 'bg-red-900/50 text-red-300 border border-red-600'
                                                                                }`}>
                                                                                {changeType === 'spike'
                                                                                    ? '📈 SPIKE'
                                                                                    : '📉 DROP'}
                                                                            </span>
                                                                        )}
                                                                        {!previousSentiment && (
                                                                            <span className='px-2 py-1 rounded-full text-xs font-medium bg-blue-900/50 text-blue-300 border border-blue-600'>
                                                                                🆕 NEW
                                                                            </span>
                                                                        )}
                                                                    </div>
                                                                    <div className='flex items-center space-x-2 mt-1'>
                                                                        <span
                                                                            className={`px-3 py-1 rounded-full text-xs font-semibold ${
                                                                                entry.sentiment >= 80
                                                                                    ? 'bg-green-900/50 text-green-300 border border-green-700'
                                                                                    : entry.sentiment <= 20
                                                                                    ? 'bg-red-900/50 text-red-300 border border-red-700'
                                                                                    : 'bg-yellow-900/50 text-yellow-300 border border-yellow-700'
                                                                            }`}>
                                                                            Sentiment: {entry.sentiment}/100
                                                                        </span>
                                                                    </div>
                                                                    <div className='mt-1'>
                                                                        <span
                                                                            className={`text-xs ${
                                                                                !previousSentiment
                                                                                    ? 'text-blue-300'
                                                                                    : changeAmount === 0
                                                                                    ? 'text-gray-400'
                                                                                    : changeAmount > 0
                                                                                    ? 'text-green-400'
                                                                                    : 'text-red-400'
                                                                            }`}>
                                                                            {changeMessage}
                                                                        </span>
                                                                    </div>
                                                                </div>
                                                            </div>
                                                            <div className='text-right'>
                                                                <p className='font-bold text-lg text-white'>
                                                                    ${entry.price.toFixed(2)}
                                                                </p>
                                                                <p
                                                                    className={`text-sm font-semibold ${
                                                                        entry.percent_change_24h >= 0
                                                                            ? 'text-green-400'
                                                                            : 'text-red-400'
                                                                    }`}>
                                                                    {entry.percent_change_24h >= 0 ? '+' : ''}
                                                                    {entry.percent_change_24h.toFixed(2)}% (24h)
                                                                </p>
                                                            </div>
                                                        </div>
                                                        <p className='text-xs text-gray-400 mt-3'>
                                                            Last analyzed:{' '}
                                                            {new Date(entry.created_at).toLocaleString()}
                                                        </p>
                                                    </div>
                                                );
                                            });
                                    })()}
                                </div>
                            )}
                        </div>
                    </div>
                </div>
            </div>

            {/* Footer */}
            <footer className='bg-gray-800 border-t border-gray-700 mt-16'>
                <div className='max-w-7xl mx-auto px-6 py-8'>
                    <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8'>
                        {/* Project Info */}
                        <div>
                            <h3 className='text-lg font-semibold text-white mb-4'>
                                Social Sentiment Processor
                            </h3>
                            <p className='text-gray-300 text-sm mb-4'>
                                A Next.js application demonstrating real-time crypto sentiment
                                analysis with background job processing.
                            </p>
                            <a
                                href='https://github.com/yourusername/social-sentiment-processor'
                                target='_blank'
                                rel='noopener noreferrer'
                                className='inline-flex items-center px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white text-sm rounded-lg transition-colors'>
                                <svg
                                    className='w-4 h-4 mr-2'
                                    fill='currentColor'
                                    viewBox='0 0 24 24'>
                                    <path
                                        fillRule='evenodd'
                                        d='M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z'
                                        clipRule='evenodd'
                                    />
                                </svg>
                                View Source Code
                            </a>
                        </div>

                        {/* Technology Stack */}
                        <div>
                            <h4 className='text-lg font-semibold text-white mb-4'>
                                Built With
                            </h4>
                            <ul className='space-y-2 text-sm'>
                                <li>
                                    <a
                                        href='https://nextjs.org'
                                        target='_blank'
                                        rel='noopener noreferrer'
                                        className='text-gray-300 hover:text-white transition-colors'>
                                        Next.js - React Framework
                                    </a>
                                </li>
                                <li>
                                    <a
                                        href='https://tailwindcss.com'
                                        target='_blank'
                                        rel='noopener noreferrer'
                                        className='text-gray-300 hover:text-white transition-colors'>
                                        Tailwind CSS - Styling
                                    </a>
                                </li>
                                <li>
                                    <a
                                        href='https://www.typescriptlang.org'
                                        target='_blank'
                                        rel='noopener noreferrer'
                                        className='text-gray-300 hover:text-white transition-colors'>
                                        TypeScript - Type Safety
                                    </a>
                                </li>
                            </ul>
                        </div>

                        {/* Services */}
                        <div>
                            <h4 className='text-lg font-semibold text-white mb-4'>
                                Powered By
                            </h4>
                            <ul className='space-y-2 text-sm'>
                                <li>
                                    <a
                                        href='https://www.inngest.com'
                                        target='_blank'
                                        rel='noopener noreferrer'
                                        className='text-blue-300 hover:text-blue-200 transition-colors'>
                                        Inngest - Background Jobs
                                    </a>
                                </li>
                                <li>
                                    <a
                                        href='https://supabase.com'
                                        target='_blank'
                                        rel='noopener noreferrer'
                                        className='text-green-300 hover:text-green-200 transition-colors'>
                                        Supabase - Database & Storage
                                    </a>
                                </li>
                                <li>
                                    <a
                                        href='https://lunarcrush.com'
                                        target='_blank'
                                        rel='noopener noreferrer'
                                        className='text-purple-300 hover:text-purple-200 transition-colors'>
                                        LunarCrush - Social Data API
                                    </a>
                                </li>
                                <li>
                                    <a
                                        href='https://vercel.com'
                                        target='_blank'
                                        rel='noopener noreferrer'
                                        className='text-gray-300 hover:text-white transition-colors'>
                                        Vercel - Deployment
                                    </a>
                                </li>
                            </ul>
                        </div>

                        {/* Links & Resources */}
                        <div>
                            <h4 className='text-lg font-semibold text-white mb-4'>
                                Resources
                            </h4>
                            <ul className='space-y-2 text-sm'>
                                <li>
                                    <a
                                        href='https://lunarcrush.com/developers/api/endpoints'
                                        target='_blank'
                                        rel='noopener noreferrer'
                                        className='text-gray-300 hover:text-white transition-colors'>
                                        LunarCrush API Docs
                                    </a>
                                </li>
                                <li>
                                    <a
                                        href='https://www.inngest.com/docs'
                                        target='_blank'
                                        rel='noopener noreferrer'
                                        className='text-gray-300 hover:text-white transition-colors'>
                                        Inngest Documentation
                                    </a>
                                </li>
                                <li>
                                    <a
                                        href='https://supabase.com/docs'
                                        target='_blank'
                                        rel='noopener noreferrer'
                                        className='text-gray-300 hover:text-white transition-colors'>
                                        Supabase Documentation
                                    </a>
                                </li>
                                <li>
                                    <a
                                        href='https://lunarcrush.com/about/api'
                                        target='_blank'
                                        rel='noopener noreferrer'
                                        className='text-gray-300 hover:text-white transition-colors'>
                                        Get LunarCrush API Key
                                    </a>
                                </li>
                            </ul>
                        </div>
                    </div>

                    {/* Bottom Bar */}
                    <div className='border-t border-gray-700 pt-6 mt-8'>
                        <div className='flex flex-col md:flex-row justify-between items-center'>
                            <p className='text-gray-400 text-sm'>
                                © 2025 Social Sentiment Processor. Built for demonstration
                                purposes.
                            </p>
                            <div className='flex items-center space-x-4 mt-4 md:mt-0'>
                                <span className='text-gray-500 text-sm'>Powered by:</span>
                                <div className='flex items-center space-x-3'>
                                    <a
                                        href='https://www.inngest.com'
                                        target='_blank'
                                        rel='noopener noreferrer'
                                        className='text-blue-400 hover:text-blue-300 transition-colors'>
                                        <span className='text-xs font-medium'>Inngest</span>
                                    </a>
                                    <span className='text-gray-600'></span>
                                    <a
                                        href='https://supabase.com'
                                        target='_blank'
                                        rel='noopener noreferrer'
                                        className='text-green-400 hover:text-green-300 transition-colors'>
                                        <span className='text-xs font-medium'>Supabase</span>
                                    </a>
                                    <span className='text-gray-600'></span>
                                    <a
                                        href='https://lunarcrush.com'
                                        target='_blank'
                                        rel='noopener noreferrer'
                                        className='text-purple-400 hover:text-purple-300 transition-colors'>
                                        <span className='text-xs font-medium'>LunarCrush</span>
                                    </a>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </footer>
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

Testing & Deployment

Local Testing

Test Your Setup:

# Terminal 1: Start Inngest dev server
npx inngest-cli@latest dev

# Terminal 2: Start Next.js
npm run dev

# Visit http://localhost:3000/dashboard and trigger a job
Enter fullscreen mode Exit fullscreen mode

Verify Everything Works:

  1. 🔍 Dashboard loads: Visit http://localhost:3000/dashboard
  2. 🚀 Job triggers: Click "Start Sentiment Analysis"
  3. 📊 Inngest processes: Check http://localhost:8288 for job execution
  4. 💾 Database saves: Verify records in your Supabase dashboard
  5. 🔔 Discord alerts: Check your Discord channel (if configured)

Debugging Tip: Use http://localhost:8288 to monitor Inngest job steps. If jobs fail, check LunarCrush API rate limits or Supabase credentials.

Deploy to Production

Deploy on Vercel (Recommended):

Deploy with Vercel

Manual Deployment Steps:

  1. Push to GitHub:
git add .
git commit -m "Social sentiment processor"
git push origin main
Enter fullscreen mode Exit fullscreen mode
  1. Connect to Vercel:

    • Visit vercel.com and sign up
    • Import your GitHub repository
    • Configure project settings
  2. Add Environment Variables in Vercel:

    • Go to Settings → Environment Variables
    • Add all variables from your .env.local:
LUNARCRUSH_API_KEY=your_api_key_here
INNGEST_SIGNING_KEY=signkey_your_key_here
INNGEST_EVENT_KEY=key_your_event_key_here
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key_here
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/your_webhook_url
Enter fullscreen mode Exit fullscreen mode

Ensure INNGEST_SIGNING_KEY and INNGEST_EVENT_KEY match your production Inngest app. Monitor jobs in the Inngest dashboard post-deployment.

  1. Configure Inngest for Production:

    • In Inngest dashboard, create a production app
    • Add your deployment URL: https://your-app.vercel.app/api/inngest
    • Update environment variables with production keys
  2. Deploy:

    • Click "Deploy" in Vercel
    • Wait for deployment to complete
    • Test your live application

Alternative Deployment Options:

  • Deploy to Netlify
  • Deploy with Railway

Live Example: You can see a deployed version of this processor at social-sentiment-processor-demo.vercel.app

Troubleshooting Common Issues

Here are solutions to the most common problems you might encounter:

Issue Solution
Inngest Function Not Triggering Check your signing key and ensure the Inngest dev server is running. Verify the /api/inngest route is accessible
Database Connection Errors Verify Supabase credentials in environment variables. Ensure tables were created successfully using the provided SQL schema
401 Unauthorized (LunarCrush) Your API key is invalid or subscription is inactive. Check the key format in your .env.local file and verify your LunarCrush subscription
No Discord Alerts Check webhook URL format and Discord channel permissions. Test the webhook manually with a curl command
Environment Variables Missing In production, ensure all environment variables are set in your deployment platform (Vercel/Netlify)
Rate Limiting (429 Errors) Individual plan is limited to 10 requests/minute. Add caching or reduce processing frequency for large datasets

🔍 Pro Debugging Tip: Check the Inngest dashboard at http://localhost:8288 during development to see detailed execution logs for each step of your background jobs.

Using AI to Extend Your Project

AI Prompt Templates

Here are example AI prompts you can use to extend this project:

Component Generation

"Create a React dashboard component for monitoring cryptocurrency sentiment processing jobs with real-time status updates, progress bars, and error handling. Include TypeScript interfaces and Tailwind CSS styling for a modern dark theme."
Enter fullscreen mode Exit fullscreen mode

Error Handling

"Add comprehensive error handling to an Inngest background job function that processes cryptocurrency data. Include retry logic for API failures, database connection issues, and rate limiting with exponential backoff."
Enter fullscreen mode Exit fullscreen mode

Advanced Features

"Extend a crypto sentiment processing system with scheduled cron jobs that run every 4 hours, intelligent alert routing based on sentiment change severity, and WebSocket notifications for real-time dashboard updates."
Enter fullscreen mode Exit fullscreen mode

Testing

"Create Jest test cases for a TypeScript Inngest function that processes cryptocurrency sentiment data. Include tests for successful processing, API failures, database errors, and alert generation with proper mocking."
Enter fullscreen mode Exit fullscreen mode

Deployment Optimization

"Optimize a Next.js cryptocurrency sentiment processor for production deployment on Vercel. Include environment variable management, error monitoring setup, database connection pooling, and auto-scaling configuration."
Enter fullscreen mode Exit fullscreen mode

Level Up: Advanced Features

Ready to make this enterprise-grade? Here are some powerful extensions:

Scheduled Processing:

// Add cron-triggered sentiment analysis
export const scheduledSentimentCheck = inngest.createFunction(
  { id: 'scheduled-sentiment-check' },
  { cron: '0 */4 * * *' }, // Every 4 hours
  async ({ event, step }) => {
    return await processSentimentData.handler({ event, step });
  }
);
Enter fullscreen mode Exit fullscreen mode

Advanced Alert Routing:

// Route alerts based on severity and user preferences
const routeAlert = (alert: SentimentAlert) => {
  const change = Math.abs(alert.percentChange);

  if (change > 50) {
    // Critical: Multiple channels
    return ['discord', 'slack', 'email'];
  } else if (change > 20) {
    // Important: Primary channel
    return ['discord'];
  }
  // Minor: Database logging only
  return ['database'];
};
Enter fullscreen mode Exit fullscreen mode

Performance Optimization:

// Add Redis caching for API responses
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

export const getCachedSentimentData = async (symbols: string[]) => {
  const cacheKey = `sentiment:${symbols.sort().join(',')}`;
  const cached = await redis.get(cacheKey);

  if (cached) {
    return JSON.parse(cached);
  }

  const data = await getCoinsWithSentiment(symbols);
  await redis.setex(cacheKey, 3600, JSON.stringify(data)); // 1 hour cache
  return data;
};
Enter fullscreen mode Exit fullscreen mode

🤖 AI Prompt for Advanced Features:

"Add enterprise features to a cryptocurrency sentiment analysis system: cron-scheduled processing, intelligent alert routing based on severity, Redis caching for API responses, and real-time WebSocket notifications using Inngest and TypeScript."
Enter fullscreen mode Exit fullscreen mode

🚀 Ready to Scale?

LunarCrush Plan Comparison:

Plan Price Rate Limits Best For
Individual $24-30/month 10 req/min, 2K/day Learning, prototypes
Builder $240-300/month 100 req/min, 20K/day Production apps
Enterprise $1000+/month Custom Large-scale systems

Upgrade Benefits:

  • Real-time data for millisecond-fresh sentiment
  • Higher rate limits for production traffic
  • Advanced metrics and social signals
  • Priority support and custom integrations

📁 Complete Code & Resources

GitHub Repository: github.com/danilobatson/social-sentiment-processor

  • Star the repo if this tutorial helped you!
  • 🍴 Fork it to customize for your needs
  • 🐛 Report issues or suggest improvements

What's Included:

  • Complete TypeScript source code with comments
  • Database migration scripts for Supabase
  • Environment setup guide with screenshots
  • Deployment instructions for multiple platforms
  • Example environment variables file
  • Additional examples and advanced features

Connect With Me on LinkedIn

If you found this tutorial helpful, I'd love to connect with you on LinkedIn! Feel free to reach out with questions, feedback, or just to share what you've built.

LinkedIn

Conclusion

Congratulations! You've successfully built a production-ready crypto sentiment processor using modern TypeScript patterns and background job processing. This system demonstrates enterprise-level architecture that can handle thousands of cryptocurrency sentiment signals without blocking your main application.

What You've Accomplished

  • Background Job Processing: Queue-based system with automatic retries
  • Type-Safe Development: End-to-end TypeScript implementation
  • Real-Time Monitoring: Professional dashboard with live updates
  • Production Deployment: Scalable architecture ready for production
  • Error Resilience: Comprehensive error handling and fallback systems

Try it now! Clone the repo, deploy, and share your results on LinkedIn.

What's Next?

Extend Your App:

  • Add user authentication with Supabase Auth
  • Implement custom sentiment scoring algorithms
  • Create mobile version with React Native + Expo
  • Upgrade to LunarCrush Builder plan for real-time millisecond data

Production Optimizations:

  • Set up monitoring with Sentry or LogRocket
  • Add rate limiting and request validation
  • Implement database connection pooling
  • Configure auto-scaling for high traffic

Advanced Features:

  • Scheduled Processing: Add cron triggers for automatic analysis
  • Custom Alerts: Route alerts based on severity (Discord, email, Slack)
  • Caching: Add Redis for improved API response times
  • Real-time UI: WebSocket updates for live dashboard

🚀 Ready to Scale?

LunarCrush Plan Comparison:

Plan Price Rate Limits Best For
Individual $24-30/month 10 req/min, 2K/day Learning, prototypes
Builder $240-300/month 100 req/min, 20K/day Production apps
Enterprise $1000+/month Custom Large-scale systems

Upgrade Benefits:

  • Real-time data for millisecond-fresh sentiment updates
  • Higher rate limits for production traffic volumes
  • Advanced metrics and comprehensive social signals
  • Priority support and custom integrations

Resources

🚀 Subscribe To Access API | View Live Demo | GitHub Repo


Built with ❤️ using LunarCrushInngestNext.jsSupabaseTypeScript

Questions? Drop them below! I respond to every comment and love helping fellow developers build amazing applications. 🚀

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.