diff --git a/public/app.js b/public/app.js
index 26c8fea..9d9e63d 100644
--- a/public/app.js
+++ b/public/app.js
@@ -712,25 +712,31 @@ function exportLogs() {
}
function openConfig() {
- showModal('Configuration Editor', `
+ showModal('Configuration Viewer', `
Loading configuration...
`, [])
- // Fetch current config
+ // Fetch config (read-only view)
fetch('/api/config')
.then(r => r.json())
- .then(config => {
+ .then(data => {
const body = `
-
-
⚠️ Advanced users only. Invalid JSON will break the bot.
+
+ ⚠️ Read-Only View
+ This is a simplified preview. To edit config:
+ 1. Open src/config.jsonc in a text editor
+ 2. Make your changes
+ 3. Save and restart the bot
+
+
+
💡 Manual editing preserves comments and complex settings
`
const buttons = [
- { cls: 'btn btn-sm btn-secondary', action: 'closeModal()', text: 'Cancel' },
- { cls: 'btn btn-sm btn-primary', action: 'saveConfig()', text: 'Save Changes' }
+ { cls: 'btn btn-sm btn-secondary', action: 'closeModal()', text: 'Close' }
]
- showModal('Configuration Editor', body, buttons)
+ showModal('Configuration Viewer', body, buttons)
})
.catch(e => {
showToast('Failed to load config: ' + e.message, 'error')
@@ -739,32 +745,9 @@ function openConfig() {
}
function saveConfig() {
- const editor = document.getElementById('configEditor')
- if (!editor) return
-
- try {
- const newConfig = JSON.parse(editor.value)
-
- fetch('/api/config', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(newConfig)
- })
- .then(r => r.json())
- .then(data => {
- if (data.success) {
- showToast('Configuration saved! Restart bot for changes to apply.', 'success')
- closeModal()
- } else {
- showToast('Save failed: ' + (data.error || 'Unknown error'), 'error')
- }
- })
- .catch(e => {
- showToast('Save failed: ' + e.message, 'error')
- })
- } catch (e) {
- showToast('Invalid JSON format: ' + e.message, 'error')
- }
+ // Config editing is disabled - this function is now unused
+ showToast('Config editing disabled. Edit src/config.jsonc manually.', 'warning')
+ closeModal()
}
function viewHistory() {
diff --git a/public/index.html b/public/index.html
index 7940906..a78b673 100644
--- a/public/index.html
+++ b/public/index.html
@@ -8,27 +8,7 @@
-
-
diff --git a/public/style-extensions.css b/public/style-extensions.css
deleted file mode 100644
index 79962da..0000000
--- a/public/style-extensions.css
+++ /dev/null
@@ -1,82 +0,0 @@
-/* Dashboard Extensions - Config Editor & History Viewer */
-
-/* Config Editor Styles */
-.config-editor {
- width: 100%;
- min-height: 400px;
-}
-
-.config-textarea {
- width: 100%;
- min-height: 400px;
- padding: var(--spacing-md);
- background: var(--bg-tertiary);
- border: 1px solid var(--border-color);
- border-radius: var(--radius-md);
- color: var(--text-primary);
- font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
- font-size: 13px;
- line-height: 1.6;
- resize: vertical;
- transition: border-color var(--transition-fast);
-}
-
-.config-textarea:focus {
- outline: none;
- border-color: var(--accent-blue);
- box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.2);
-}
-
-.config-hint {
- margin-top: var(--spacing-sm);
- font-size: 13px;
- color: var(--text-muted);
- text-align: center;
-}
-
-.config-loading {
- text-align: center;
- padding: var(--spacing-xl);
- color: var(--text-secondary);
-}
-
-/* History Viewer Styles */
-.history-list {
- max-height: 500px;
- overflow-y: auto;
-}
-
-.history-row {
- padding: var(--spacing-md);
- margin-bottom: var(--spacing-sm);
- background: var(--bg-tertiary);
- border-radius: var(--radius-md);
- border-left: 3px solid var(--accent-blue);
- transition: all var(--transition-fast);
-}
-
-.history-row:hover {
- background: var(--bg-secondary);
- border-left-color: var(--accent-green);
- transform: translateX(2px);
-}
-
-.history-date {
- font-size: 14px;
- font-weight: 600;
- color: var(--text-primary);
- margin-bottom: var(--spacing-xs);
-}
-
-.history-stats {
- display: flex;
- gap: var(--spacing-md);
- font-size: 13px;
- color: var(--text-secondary);
-}
-
-.history-stats span {
- padding: 2px 8px;
- background: var(--bg-primary);
- border-radius: var(--radius-sm);
-}
\ No newline at end of file
diff --git a/public/style.css b/public/style.css
index c6c06a1..20b05f0 100644
--- a/public/style.css
+++ b/public/style.css
@@ -877,4 +877,328 @@ body {
.control-grid {
grid-template-columns: 1fr;
}
+}
+
+/* =================================================================== */
+/* ICON STYLES (moved from inline) */
+/* =================================================================== */
+
+.icon {
+ width: 1em;
+ height: 1em;
+ fill: currentColor;
+ vertical-align: -0.125em;
+}
+
+.icon-lg {
+ width: 1.25em;
+ height: 1.25em;
+}
+
+.icon-sm {
+ width: 0.625em;
+ height: 0.625em;
+}
+
+/* =================================================================== */
+/* CONFIG EDITOR & HISTORY VIEWER (moved from style-extensions.css) */
+/* =================================================================== */
+
+.config-editor {
+ width: 100%;
+ min-height: 400px;
+}
+
+.config-textarea {
+ width: 100%;
+ min-height: 400px;
+ padding: var(--spacing-md);
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ color: var(--text-primary);
+ font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
+ font-size: 13px;
+ line-height: 1.6;
+ resize: vertical;
+ transition: border-color var(--transition-fast);
+}
+
+.config-textarea:focus {
+ outline: none;
+ border-color: var(--accent-blue);
+ box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.2);
+}
+
+.config-hint {
+ margin-top: var(--spacing-sm);
+ font-size: 13px;
+ color: var(--text-muted);
+ text-align: center;
+}
+
+.config-warning {
+ padding: var(--spacing-md);
+ margin-bottom: var(--spacing-md);
+ background: var(--accent-orange);
+ color: var(--bg-primary);
+ border-radius: var(--radius-md);
+ font-size: 14px;
+ line-height: 1.8;
+}
+
+.config-warning strong {
+ font-weight: 600;
+ display: block;
+ margin-bottom: var(--spacing-xs);
+}
+
+.config-warning code {
+ background: rgba(0, 0, 0, 0.2);
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 13px;
+}
+
+.config-loading {
+ text-align: center;
+ padding: var(--spacing-xl);
+ color: var(--text-secondary);
+}
+
+.history-list {
+ max-height: 500px;
+ overflow-y: auto;
+}
+
+.history-row {
+ padding: var(--spacing-md);
+ margin-bottom: var(--spacing-sm);
+ background: var(--bg-tertiary);
+ border-radius: var(--radius-md);
+ border-left: 3px solid var(--accent-blue);
+ transition: all var(--transition-fast);
+ animation: slideInLeft 0.3s ease-out;
+}
+
+.history-row:hover {
+ background: var(--bg-secondary);
+ border-left-color: var(--accent-green);
+ transform: translateX(4px);
+}
+
+.history-date {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: var(--spacing-xs);
+}
+
+.history-stats {
+ display: flex;
+ gap: var(--spacing-md);
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.history-stats span {
+ padding: 2px 8px;
+ background: var(--bg-primary);
+ border-radius: var(--radius-sm);
+ transition: all var(--transition-fast);
+}
+
+.history-stats span:hover {
+ background: var(--accent-blue);
+ color: var(--bg-primary);
+ transform: scale(1.05);
+}
+
+/* =================================================================== */
+/* PROFESSIONAL ANIMATIONS */
+/* =================================================================== */
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes slideInLeft {
+ from {
+ opacity: 0;
+ transform: translateX(-20px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+@keyframes slideInRight {
+ from {
+ opacity: 0;
+ transform: translateX(20px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+@keyframes slideInUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes pulse {
+
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes scaleIn {
+ from {
+ opacity: 0;
+ transform: scale(0.9);
+ }
+
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes shimmer {
+ 0% {
+ background-position: -1000px 0;
+ }
+
+ 100% {
+ background-position: 1000px 0;
+ }
+}
+
+/* Apply animations to elements */
+.card {
+ animation: slideInUp 0.4s ease-out;
+}
+
+.stat-card {
+ animation: scaleIn 0.3s ease-out;
+}
+
+.stat-card:nth-child(2) {
+ animation-delay: 0.1s;
+}
+
+.stat-card:nth-child(3) {
+ animation-delay: 0.2s;
+}
+
+.stat-card:nth-child(4) {
+ animation-delay: 0.3s;
+}
+
+.log-entry {
+ animation: slideInLeft 0.2s ease-out;
+}
+
+.account-item {
+ animation: slideInRight 0.3s ease-out;
+}
+
+.toast {
+ animation: slideInRight 0.3s ease-out;
+}
+
+.modal.show {
+ animation: fadeIn 0.3s ease-out;
+}
+
+.modal-content {
+ animation: scaleIn 0.3s ease-out;
+}
+
+.status-badge {
+ transition: all var(--transition-fast);
+}
+
+.status-badge.status-running {
+ animation: pulse 2s ease-in-out infinite;
+}
+
+/* Loading skeleton animation */
+.loading-skeleton {
+ background: linear-gradient(90deg,
+ var(--bg-tertiary) 0%,
+ var(--bg-secondary) 50%,
+ var(--bg-tertiary) 100%);
+ background-size: 200% 100%;
+ animation: shimmer 1.5s ease-in-out infinite;
+ border-radius: var(--radius-md);
+}
+
+/* Smooth transitions for interactive elements */
+.btn,
+.control-btn,
+.action-btn,
+.period-btn {
+ transition: all var(--transition-fast);
+}
+
+.btn:hover,
+.control-btn:hover,
+.action-btn:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md);
+}
+
+.btn:active,
+.control-btn:active,
+.action-btn:active {
+ transform: translateY(0);
+}
+
+/* Chart containers smooth entry */
+.chart-container {
+ animation: fadeIn 0.5s ease-out;
+}
+
+/* Stats value counter animation */
+.stat-value {
+ transition: all var(--transition-normal);
+}
+
+.stat-value.updating {
+ animation: pulse 0.5s ease-out;
}
\ No newline at end of file
diff --git a/src/dashboard/StatsManager.ts b/src/dashboard/StatsManager.ts
new file mode 100644
index 0000000..98b4f6c
--- /dev/null
+++ b/src/dashboard/StatsManager.ts
@@ -0,0 +1,262 @@
+/**
+ * StatsManager - Persistent dashboard statistics system
+ * Saves all metrics to JSON files for persistence across restarts
+ */
+
+import fs from 'fs'
+import path from 'path'
+
+export interface DailyStats {
+ date: string // ISO date (YYYY-MM-DD)
+ totalPoints: number
+ accountsCompleted: number
+ accountsWithErrors: number
+ totalSearches: number
+ totalActivities: number
+ runDuration: number // milliseconds
+}
+
+export interface AccountDailyStats {
+ email: string
+ date: string
+ pointsEarned: number
+ desktopSearches: number
+ mobileSearches: number
+ activitiesCompleted: number
+ errors: string[]
+ completedAt?: string // ISO timestamp
+}
+
+export interface GlobalStats {
+ totalRunsAllTime: number
+ totalPointsAllTime: number
+ averagePointsPerDay: number
+ lastRunDate?: string
+ firstRunDate?: string
+}
+
+export class StatsManager {
+ private statsDir: string
+ private dailyStatsPath: string
+ private globalStatsPath: string
+
+ constructor() {
+ this.statsDir = path.join(process.cwd(), 'sessions', 'dashboard-stats')
+ this.dailyStatsPath = path.join(this.statsDir, 'daily')
+ this.globalStatsPath = path.join(this.statsDir, 'global.json')
+
+ this.ensureDirectories()
+ }
+
+ private ensureDirectories(): void {
+ if (!fs.existsSync(this.statsDir)) {
+ fs.mkdirSync(this.statsDir, { recursive: true })
+ }
+ if (!fs.existsSync(this.dailyStatsPath)) {
+ fs.mkdirSync(this.dailyStatsPath, { recursive: true })
+ }
+ if (!fs.existsSync(this.globalStatsPath)) {
+ this.saveGlobalStats({
+ totalRunsAllTime: 0,
+ totalPointsAllTime: 0,
+ averagePointsPerDay: 0
+ })
+ }
+ }
+
+ /**
+ * Save daily stats (one file per day)
+ */
+ saveDailyStats(stats: DailyStats): void {
+ try {
+ const filePath = path.join(this.dailyStatsPath, `${stats.date}.json`)
+ fs.writeFileSync(filePath, JSON.stringify(stats, null, 2), 'utf-8')
+ } catch (error) {
+ console.error('[STATS] Failed to save daily stats:', error)
+ }
+ }
+
+ /**
+ * Load daily stats for specific date
+ */
+ loadDailyStats(date: string): DailyStats | null {
+ try {
+ const filePath = path.join(this.dailyStatsPath, `${date}.json`)
+ if (!fs.existsSync(filePath)) return null
+
+ const data = fs.readFileSync(filePath, 'utf-8')
+ return JSON.parse(data) as DailyStats
+ } catch {
+ return null
+ }
+ }
+
+ /**
+ * Get stats for last N days
+ */
+ getLastNDays(days: number): DailyStats[] {
+ const result: DailyStats[] = []
+ const today = new Date()
+
+ for (let i = 0; i < days; i++) {
+ const date = new Date(today)
+ date.setDate(date.getDate() - i)
+ const dateStr = date.toISOString().slice(0, 10)
+
+ const stats = this.loadDailyStats(dateStr)
+ if (stats) {
+ result.push(stats)
+ } else {
+ // Create empty stats for missing days
+ result.push({
+ date: dateStr,
+ totalPoints: 0,
+ accountsCompleted: 0,
+ accountsWithErrors: 0,
+ totalSearches: 0,
+ totalActivities: 0,
+ runDuration: 0
+ })
+ }
+ }
+
+ return result.reverse() // Chronological order
+ }
+
+ /**
+ * Save account-specific daily stats
+ */
+ saveAccountDailyStats(stats: AccountDailyStats): void {
+ try {
+ const accountDir = path.join(this.dailyStatsPath, 'accounts')
+ if (!fs.existsSync(accountDir)) {
+ fs.mkdirSync(accountDir, { recursive: true })
+ }
+
+ const maskedEmail = stats.email.replace(/@.*/, '@***')
+ const filePath = path.join(accountDir, `${maskedEmail}_${stats.date}.json`)
+ fs.writeFileSync(filePath, JSON.stringify(stats, null, 2), 'utf-8')
+ } catch (error) {
+ console.error('[STATS] Failed to save account stats:', error)
+ }
+ }
+
+ /**
+ * Get all account stats for a specific date
+ */
+ getAccountStatsForDate(date: string): AccountDailyStats[] {
+ try {
+ const accountDir = path.join(this.dailyStatsPath, 'accounts')
+ if (!fs.existsSync(accountDir)) return []
+
+ const files = fs.readdirSync(accountDir)
+ .filter(f => f.endsWith(`_${date}.json`))
+
+ return files.map(file => {
+ const data = fs.readFileSync(path.join(accountDir, file), 'utf-8')
+ return JSON.parse(data) as AccountDailyStats
+ })
+ } catch {
+ return []
+ }
+ }
+
+ /**
+ * Save global (all-time) statistics
+ */
+ saveGlobalStats(stats: GlobalStats): void {
+ try {
+ fs.writeFileSync(this.globalStatsPath, JSON.stringify(stats, null, 2), 'utf-8')
+ } catch (error) {
+ console.error('[STATS] Failed to save global stats:', error)
+ }
+ }
+
+ /**
+ * Load global statistics
+ */
+ loadGlobalStats(): GlobalStats {
+ try {
+ if (!fs.existsSync(this.globalStatsPath)) {
+ return {
+ totalRunsAllTime: 0,
+ totalPointsAllTime: 0,
+ averagePointsPerDay: 0
+ }
+ }
+
+ const data = fs.readFileSync(this.globalStatsPath, 'utf-8')
+ return JSON.parse(data) as GlobalStats
+ } catch {
+ return {
+ totalRunsAllTime: 0,
+ totalPointsAllTime: 0,
+ averagePointsPerDay: 0
+ }
+ }
+ }
+
+ /**
+ * Increment global stats after a run
+ */
+ incrementGlobalStats(pointsEarned: number): void {
+ const stats = this.loadGlobalStats()
+ const today = new Date().toISOString().slice(0, 10)
+
+ stats.totalRunsAllTime++
+ stats.totalPointsAllTime += pointsEarned
+ stats.lastRunDate = today
+
+ if (!stats.firstRunDate) {
+ stats.firstRunDate = today
+ }
+
+ // Calculate average (last 30 days)
+ const last30Days = this.getLastNDays(30)
+ const totalPoints30Days = last30Days.reduce((sum, day) => sum + day.totalPoints, 0)
+ stats.averagePointsPerDay = Math.round(totalPoints30Days / 30)
+
+ this.saveGlobalStats(stats)
+ }
+
+ /**
+ * Get all available stat dates
+ */
+ getAllStatDates(): string[] {
+ try {
+ const files = fs.readdirSync(this.dailyStatsPath)
+ .filter(f => f.endsWith('.json') && f !== 'global.json')
+ .map(f => f.replace('.json', ''))
+ .sort()
+ .reverse()
+
+ return files
+ } catch {
+ return []
+ }
+ }
+
+ /**
+ * Delete old stats (keep last N days)
+ */
+ pruneOldStats(keepDays: number = 90): void {
+ try {
+ const allDates = this.getAllStatDates()
+ const cutoffDate = new Date()
+ cutoffDate.setDate(cutoffDate.getDate() - keepDays)
+ const cutoffStr = cutoffDate.toISOString().slice(0, 10)
+
+ for (const date of allDates) {
+ if (date < cutoffStr) {
+ const filePath = path.join(this.dailyStatsPath, `${date}.json`)
+ fs.unlinkSync(filePath)
+ }
+ }
+ } catch (error) {
+ console.error('[STATS] Failed to prune old stats:', error)
+ }
+ }
+}
+
+// Singleton instance
+export const statsManager = new StatsManager()
diff --git a/src/dashboard/routes.ts b/src/dashboard/routes.ts
index 394bd48..faed274 100644
--- a/src/dashboard/routes.ts
+++ b/src/dashboard/routes.ts
@@ -5,6 +5,7 @@ import { AccountHistory } from '../util/state/AccountHistory'
import { getConfigPath, loadAccounts, loadConfig } from '../util/state/Load'
import { botController } from './BotController'
import { dashboardState } from './state'
+import { statsManager } from './StatsManager'
export const apiRouter = Router()
@@ -110,42 +111,44 @@ apiRouter.get('/history', (_req: Request, res: Response): void => {
// GET /api/config - Current config (tokens masked)
apiRouter.get('/config', (_req: Request, res: Response) => {
try {
+ // CRITICAL: Load raw config.jsonc to preserve comments
+ const configPath = getConfigPath()
+ if (!fs.existsSync(configPath)) {
+ res.status(404).json({ error: 'Config file not found' })
+ return
+ }
+
+ // Read raw JSONC content (preserves comments)
+ const rawConfig = fs.readFileSync(configPath, 'utf-8')
+
+ // Parse and sanitize for display
const config = loadConfig()
const safe = JSON.parse(JSON.stringify(config))
- // Mask sensitive data
+ // Mask sensitive data (but keep structure)
if (safe.webhook?.url) safe.webhook.url = maskUrl(safe.webhook.url)
if (safe.conclusionWebhook?.url) safe.conclusionWebhook.url = maskUrl(safe.conclusionWebhook.url)
if (safe.ntfy?.authToken) safe.ntfy.authToken = '***'
- res.json(safe)
+ // WARNING: Show user this is read-only view
+ res.json({
+ config: safe,
+ warning: 'This is a simplified view. Direct file editing recommended for complex changes.',
+ rawPreview: rawConfig.split('\\n').slice(0, 10).join('\\n') + '\\n...' // First 10 lines
+ })
} catch (error) {
res.status(500).json({ error: getErr(error) })
}
})
-// POST /api/config - Update config (with backup)
+// POST /api/config - Update config (DISABLED - manual editing only)
apiRouter.post('/config', (req: Request, res: Response): void => {
- try {
- const newConfig = req.body
- const configPath = getConfigPath()
-
- if (!configPath || !fs.existsSync(configPath)) {
- res.status(404).json({ error: 'Config file not found' })
- return
- }
-
- // Backup current config
- const backupPath = `${configPath}.backup.${Date.now()}`
- fs.copyFileSync(configPath, backupPath)
-
- // Write new config
- fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2), 'utf-8')
-
- res.json({ success: true, backup: backupPath })
- } catch (error) {
- res.status(500).json({ error: getErr(error) })
- }
+ // DISABLED: Config editing via API is unsafe (loses JSONC comments)
+ // Users should edit config.jsonc manually
+ res.status(403).json({
+ error: 'Config editing via dashboard is disabled to preserve JSONC format.',
+ hint: 'Please edit src/config.jsonc manually with a text editor.'
+ })
})
// POST /api/start - Start bot in background
@@ -235,7 +238,12 @@ apiRouter.get('/metrics', (_req: Request, res: Response) => {
const accountsWithErrors = accounts.filter(a => a.errors && a.errors.length > 0).length
const avgPoints = accounts.length > 0 ? Math.round(totalPoints / accounts.length) : 0
+ // Load persistent stats
+ const globalStats = statsManager.loadGlobalStats()
+ const todayStats = statsManager.loadDailyStats(new Date().toISOString().slice(0, 10))
+
res.json({
+ // Current session metrics
totalAccounts: accounts.length,
totalPoints,
avgPoints,
@@ -243,13 +251,73 @@ apiRouter.get('/metrics', (_req: Request, res: Response) => {
accountsRunning: accounts.filter(a => a.status === 'running').length,
accountsCompleted: accounts.filter(a => a.status === 'completed').length,
accountsIdle: accounts.filter(a => a.status === 'idle').length,
- accountsError: accounts.filter(a => a.status === 'error').length
+ accountsError: accounts.filter(a => a.status === 'error').length,
+
+ // Persistent stats
+ globalStats: {
+ totalRunsAllTime: globalStats.totalRunsAllTime,
+ totalPointsAllTime: globalStats.totalPointsAllTime,
+ averagePointsPerDay: globalStats.averagePointsPerDay,
+ lastRunDate: globalStats.lastRunDate,
+ firstRunDate: globalStats.firstRunDate
+ },
+
+ // Today's stats
+ todayStats: todayStats || {
+ totalPoints: 0,
+ accountsCompleted: 0,
+ accountsWithErrors: 0
+ }
})
} catch (error) {
res.status(500).json({ error: getErr(error) })
}
})
+// GET /api/stats/history/:days - Get stats history
+apiRouter.get('/stats/history/:days', (req: Request, res: Response) => {
+ try {
+ const daysParam = req.params.days
+ if (!daysParam) {
+ res.status(400).json({ error: 'Days parameter required' })
+ return
+ }
+ const days = Math.min(parseInt(daysParam, 10) || 7, 90)
+ const history = statsManager.getLastNDays(days)
+ res.json(history)
+ } catch (error) {
+ res.status(500).json({ error: getErr(error) })
+ }
+})
+
+// POST /api/stats/record - Record new stats (called by bot after run)
+apiRouter.post('/stats/record', (req: Request, res: Response) => {
+ try {
+ const { pointsEarned, accountsCompleted, accountsWithErrors, totalSearches, totalActivities, runDuration } = req.body
+
+ const today = new Date().toISOString().slice(0, 10)
+ const existingStats = statsManager.loadDailyStats(today)
+
+ // Merge with existing stats if run multiple times today
+ const dailyStats = {
+ date: today,
+ totalPoints: (existingStats?.totalPoints || 0) + (pointsEarned || 0),
+ accountsCompleted: (existingStats?.accountsCompleted || 0) + (accountsCompleted || 0),
+ accountsWithErrors: (existingStats?.accountsWithErrors || 0) + (accountsWithErrors || 0),
+ totalSearches: (existingStats?.totalSearches || 0) + (totalSearches || 0),
+ totalActivities: (existingStats?.totalActivities || 0) + (totalActivities || 0),
+ runDuration: (existingStats?.runDuration || 0) + (runDuration || 0)
+ }
+
+ statsManager.saveDailyStats(dailyStats)
+ statsManager.incrementGlobalStats(pointsEarned || 0)
+
+ res.json({ success: true, stats: dailyStats })
+ } catch (error) {
+ res.status(500).json({ error: getErr(error) })
+ }
+})
+
// GET /api/account/:email - Get specific account details
apiRouter.get('/account/:email', (req: Request, res: Response): void => {
try {