import { eq, desc } from 'drizzle-orm'; import { getDatabase, items, priceHistory } from '@poe2data/database'; export interface TrendMover { id: number; name: string; category: string; iconUrl: string | null; currentValue: number; change7d: number; volume: number; chaosValue: number; } export interface CategorySummary { category: string; itemCount: number; topItem: string; topValue: number; avgChange7d: number; } export interface LeagueStartPriority { name: string; category: string; currentValue: number; change7d: number; volume: number; priority: 'high' | 'medium' | 'low'; reason: string; } export class TrendAnalysisService { private db = getDatabase(); /** * Get all items with current prices */ async getAllItems(category?: string): Promise { // Get all items const allItems = await this.db.select().from(items).all(); // Get latest price for each item - using Drizzle query builder // We'll get all prices and pick the latest per item in JS const allPrices = await this.db .select() .from(priceHistory) .orderBy(desc(priceHistory.recordedAt)) .all(); // Group by item and get the latest const priceMap = new Map(); for (const price of allPrices) { if (!priceMap.has(price.itemId)) { priceMap.set(price.itemId, price); } } let result = allItems.map(item => { const price = priceMap.get(item.id); return { id: item.id, name: item.name, category: item.category, iconUrl: item.iconUrl, currentValue: price?.divineValue || 0, change7d: price?.change7d || 0, volume: price?.volume || 0, chaosValue: (price?.divineValue || 0) * (price?.chaosRate || 42), }; }); if (category) { result = result.filter(i => i.category === category); } return result.sort((a, b) => b.currentValue - a.currentValue); } /** * Get top movers (gainers and losers) */ async getTopMovers(limit: number = 20): Promise<{ gainers: TrendMover[]; losers: TrendMover[] }> { const allItems = await this.getAllItems(); // Filter items with valid change data const movers = allItems.filter(m => m.change7d !== 0 && m.currentValue > 0); // Sort for gainers (highest positive change) const gainers = [...movers] .filter(m => m.change7d > 0) .sort((a, b) => b.change7d - a.change7d) .slice(0, limit); // Sort for losers (most negative change) const losers = [...movers] .filter(m => m.change7d < 0) .sort((a, b) => a.change7d - b.change7d) .slice(0, limit); return { gainers, losers }; } /** * Get category summaries */ async getCategorySummaries(): Promise { const allItems = await this.getAllItems(); const byCategory = new Map(); for (const item of allItems) { const list = byCategory.get(item.category) || []; list.push(item); byCategory.set(item.category, list); } const summaries: CategorySummary[] = []; for (const [category, categoryItems] of byCategory) { const sorted = categoryItems.sort((a, b) => b.currentValue - a.currentValue); const validChanges = categoryItems.filter(i => i.change7d !== 0); const avgChange = validChanges.length > 0 ? validChanges.reduce((sum, i) => sum + i.change7d, 0) / validChanges.length : 0; summaries.push({ category, itemCount: categoryItems.length, topItem: sorted[0]?.name || 'N/A', topValue: sorted[0]?.currentValue || 0, avgChange7d: avgChange, }); } return summaries.sort((a, b) => b.topValue - a.topValue); } /** * Get league start priorities - items to farm early */ async getLeagueStartPriorities(): Promise { const allItems = await this.getAllItems(); const priorities: LeagueStartPriority[] = []; for (const item of allItems) { let priority: 'high' | 'medium' | 'low' = 'low'; let reason = ''; // High priority: High value + rising + good volume if (item.currentValue > 1 && item.change7d > 20 && item.volume > 100) { priority = 'high'; reason = 'High value, rising price, good demand'; } // High priority: Very high value items else if (item.currentValue > 50) { priority = 'high'; reason = 'Very high value item'; } // Medium priority: Rising prices else if (item.change7d > 50) { priority = 'medium'; reason = 'Rapidly rising price'; } // Medium priority: Decent value with volume else if (item.currentValue > 0.5 && item.volume > 500) { priority = 'medium'; reason = 'Good value with high liquidity'; } // Skip low value items else if (item.currentValue < 0.01) { continue; } // Low priority: everything else valuable else if (item.currentValue > 0.1) { priority = 'low'; reason = 'Moderate value'; } else { continue; } priorities.push({ name: item.name, category: item.category, currentValue: item.currentValue, change7d: item.change7d, volume: item.volume, priority, reason, }); } // Sort by priority then value const priorityOrder = { high: 0, medium: 1, low: 2 }; return priorities.sort((a, b) => { const pDiff = priorityOrder[a.priority] - priorityOrder[b.priority]; if (pDiff !== 0) return pDiff; return b.currentValue - a.currentValue; }); } /** * Get price history for an item */ async getItemHistory(itemId: number): Promise<{ timestamp: number; value: number; volume: number }[]> { const history = await this.db .select({ recordedAt: priceHistory.recordedAt, divineValue: priceHistory.divineValue, volume: priceHistory.volume, }) .from(priceHistory) .where(eq(priceHistory.itemId, itemId)) .orderBy(priceHistory.recordedAt) .all(); return history.map(h => ({ timestamp: h.recordedAt?.getTime() || 0, value: h.divineValue || 0, volume: h.volume || 0, })); } }