diff --git a/crud.py b/crud.py index d54d2a6..da386fc 100644 --- a/crud.py +++ b/crud.py @@ -197,114 +197,228 @@ async def get_client_transactions( async def get_client_analytics(user_id: str, time_range: str = "30d") -> Optional[ClientAnalytics]: """Get client performance analytics""" - # Get client ID - client = await db.fetchone( - "SELECT id FROM satmachineadmin.dca_clients WHERE user_id = :user_id", - {"user_id": user_id} - ) + try: + from datetime import datetime + + # Get client ID + client = await db.fetchone( + "SELECT id FROM satmachineadmin.dca_clients WHERE user_id = :user_id", + {"user_id": user_id} + ) + + if not client: + print(f"No client found for user_id: {user_id}") + return None + + print(f"Found client {client['id']} for user {user_id}, loading analytics for time_range: {time_range}") - if not client: - return None - - # Calculate date range - if time_range == "7d": - start_date = datetime.now() - timedelta(days=7) - elif time_range == "30d": - start_date = datetime.now() - timedelta(days=30) - elif time_range == "90d": - start_date = datetime.now() - timedelta(days=90) - elif time_range == "1y": - start_date = datetime.now() - timedelta(days=365) - else: # "all" - start_date = datetime(2020, 1, 1) # Arbitrary early date - - # Get cost basis history (running average) - cost_basis_data = await db.fetchall( - """ - SELECT - created_at, - amount_sats, - amount_fiat, - exchange_rate, - SUM(amount_sats) OVER (ORDER BY created_at) as cumulative_sats, - SUM(amount_fiat) OVER (ORDER BY created_at) as cumulative_fiat - FROM satmachineadmin.dca_payments - WHERE client_id = :client_id - AND status = 'confirmed' - AND created_at >= :start_date - ORDER BY created_at - """, - {"client_id": client["id"], "start_date": start_date} - ) - - # Build cost basis history - cost_basis_history = [] - for record in cost_basis_data: - avg_cost_basis = record["cumulative_sats"] / record["cumulative_fiat"] if record["cumulative_fiat"] > 0 else 0 - cost_basis_history.append({ - "date": record["created_at"].isoformat(), - "average_cost_basis": avg_cost_basis, - "cumulative_sats": record["cumulative_sats"], - "cumulative_fiat": record["cumulative_fiat"] - }) - - # Get accumulation timeline (daily/weekly aggregation) - accumulation_data = await db.fetchall( - """ - SELECT - DATE(created_at) as date, - SUM(amount_sats) as daily_sats, - SUM(amount_fiat) as daily_fiat, - COUNT(*) as daily_transactions - FROM satmachineadmin.dca_payments - WHERE client_id = :client_id - AND status = 'confirmed' - AND created_at >= :start_date - GROUP BY DATE(created_at) - ORDER BY date - """, - {"client_id": client["id"], "start_date": start_date} - ) - - accumulation_timeline = [ - { - "date": record["date"], - "sats": record["daily_sats"], - "fiat": record["daily_fiat"], - "transactions": record["daily_transactions"] + # Calculate date range + if time_range == "7d": + start_date = datetime.now() - timedelta(days=7) + elif time_range == "30d": + start_date = datetime.now() - timedelta(days=30) + elif time_range == "90d": + start_date = datetime.now() - timedelta(days=90) + elif time_range == "1y": + start_date = datetime.now() - timedelta(days=365) + else: # "all" + start_date = datetime(2020, 1, 1) # Arbitrary early date + + # Get cost basis history (running average) + cost_basis_data = await db.fetchall( + """ + SELECT + COALESCE(transaction_time, created_at) as transaction_date, + amount_sats, + amount_fiat, + exchange_rate, + SUM(amount_sats) OVER (ORDER BY COALESCE(transaction_time, created_at)) as cumulative_sats, + SUM(amount_fiat) OVER (ORDER BY COALESCE(transaction_time, created_at)) as cumulative_fiat + FROM satmachineadmin.dca_payments + WHERE client_id = :client_id + AND status = 'confirmed' + AND COALESCE(transaction_time, created_at) IS NOT NULL + AND COALESCE(transaction_time, created_at) >= :start_date + ORDER BY COALESCE(transaction_time, created_at) + """, + {"client_id": client["id"], "start_date": start_date} + ) + + # Build cost basis history + cost_basis_history = [] + for record in cost_basis_data: + avg_cost_basis = record["cumulative_sats"] / record["cumulative_fiat"] if record["cumulative_fiat"] > 0 else 0 + # Use transaction_date (which is COALESCE(transaction_time, created_at)) + date_to_use = record["transaction_date"] + if date_to_use is None: + print(f"Warning: Null date in cost basis data, skipping record") + continue + elif hasattr(date_to_use, 'isoformat'): + # This is a datetime object + date_str = date_to_use.isoformat() + elif hasattr(date_to_use, 'strftime'): + # This is a date object + date_str = date_to_use.strftime('%Y-%m-%d') + elif isinstance(date_to_use, (int, float)): + # This might be a Unix timestamp - check if it's in a reasonable range + timestamp = float(date_to_use) + # Check if this looks like a timestamp (between 1970 and 2100) + if 0 < timestamp < 4102444800: # Jan 1, 2100 + # Could be seconds or milliseconds + if timestamp > 1000000000000: # Likely milliseconds + timestamp = timestamp / 1000 + date_str = datetime.fromtimestamp(timestamp).isoformat() + else: + # Not a timestamp, treat as string + date_str = str(date_to_use) + print(f"Warning: Numeric date value out of timestamp range: {date_to_use}") + elif isinstance(date_to_use, str) and date_to_use.isdigit(): + # This is a numeric string - might be a timestamp + timestamp = float(date_to_use) + # Check if this looks like a timestamp + if 0 < timestamp < 4102444800: # Jan 1, 2100 + # Could be seconds or milliseconds + if timestamp > 1000000000000: # Likely milliseconds + timestamp = timestamp / 1000 + date_str = datetime.fromtimestamp(timestamp).isoformat() + else: + # Not a timestamp, treat as string + date_str = str(date_to_use) + print(f"Warning: Numeric date string out of timestamp range: {date_to_use}") + else: + # Convert string representation to proper format + date_str = str(date_to_use) + print(f"Warning: Unexpected date format: {date_to_use} (type: {type(date_to_use)})") + + cost_basis_history.append({ + "date": date_str, + "average_cost_basis": avg_cost_basis, + "cumulative_sats": record["cumulative_sats"], + "cumulative_fiat": record["cumulative_fiat"] + }) + + # Get accumulation timeline (daily/weekly aggregation) + accumulation_data = await db.fetchall( + """ + SELECT + DATE(COALESCE(transaction_time, created_at)) as date, + SUM(amount_sats) as daily_sats, + SUM(amount_fiat) as daily_fiat, + COUNT(*) as daily_transactions + FROM satmachineadmin.dca_payments + WHERE client_id = :client_id + AND status = 'confirmed' + AND COALESCE(transaction_time, created_at) IS NOT NULL + AND COALESCE(transaction_time, created_at) >= :start_date + GROUP BY DATE(COALESCE(transaction_time, created_at)) + ORDER BY date + """, + {"client_id": client["id"], "start_date": start_date} + ) + + accumulation_timeline = [] + for record in accumulation_data: + # Handle date conversion safely + date_value = record["date"] + if date_value is None: + print(f"Warning: Null date in accumulation data, skipping record") + continue + elif hasattr(date_value, 'isoformat'): + # This is a datetime object + date_str = date_value.isoformat() + elif hasattr(date_value, 'strftime'): + # This is a date object (from DATE() function) + date_str = date_value.strftime('%Y-%m-%d') + elif isinstance(date_value, (int, float)): + # This might be a Unix timestamp - check if it's in a reasonable range + timestamp = float(date_value) + # Check if this looks like a timestamp (between 1970 and 2100) + if 0 < timestamp < 4102444800: # Jan 1, 2100 + # Could be seconds or milliseconds + if timestamp > 1000000000000: # Likely milliseconds + timestamp = timestamp / 1000 + date_str = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d') + else: + # Not a timestamp, treat as string + date_str = str(date_value) + print(f"Warning: Numeric accumulation date out of timestamp range: {date_value}") + elif isinstance(date_value, str) and date_value.isdigit(): + # This is a numeric string - might be a timestamp + timestamp = float(date_value) + # Check if this looks like a timestamp + if 0 < timestamp < 4102444800: # Jan 1, 2100 + # Could be seconds or milliseconds + if timestamp > 1000000000000: # Likely milliseconds + timestamp = timestamp / 1000 + date_str = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d') + else: + # Not a timestamp, treat as string + date_str = str(date_value) + print(f"Warning: Numeric accumulation date string out of timestamp range: {date_value}") + else: + # Convert string representation to proper format + date_str = str(date_value) + print(f"Warning: Unexpected accumulation date format: {date_value} (type: {type(date_value)})") + + accumulation_timeline.append({ + "date": date_str, + "sats": record["daily_sats"], + "fiat": record["daily_fiat"], + "transactions": record["daily_transactions"] + }) + + # Get transaction frequency metrics + frequency_stats = await db.fetchone( + """ + SELECT + COUNT(*) as total_transactions, + AVG(amount_sats) as avg_sats_per_tx, + AVG(amount_fiat) as avg_fiat_per_tx, + MIN(COALESCE(transaction_time, created_at)) as first_tx, + MAX(COALESCE(transaction_time, created_at)) as last_tx + FROM satmachineadmin.dca_payments + WHERE client_id = :client_id AND status = 'confirmed' + """, + {"client_id": client["id"]} + ) + + # Build transaction frequency with safe date handling + transaction_frequency = { + "total_transactions": frequency_stats["total_transactions"] if frequency_stats else 0, + "avg_sats_per_transaction": frequency_stats["avg_sats_per_tx"] if frequency_stats else 0, + "avg_fiat_per_transaction": frequency_stats["avg_fiat_per_tx"] if frequency_stats else 0, + "first_transaction": None, + "last_transaction": None } - for record in accumulation_data - ] + + # Handle first_tx date safely + if frequency_stats and frequency_stats["first_tx"]: + first_tx = frequency_stats["first_tx"] + if hasattr(first_tx, 'isoformat'): + transaction_frequency["first_transaction"] = first_tx.isoformat() + else: + transaction_frequency["first_transaction"] = str(first_tx) + + # Handle last_tx date safely + if frequency_stats and frequency_stats["last_tx"]: + last_tx = frequency_stats["last_tx"] + if hasattr(last_tx, 'isoformat'): + transaction_frequency["last_transaction"] = last_tx.isoformat() + else: + transaction_frequency["last_transaction"] = str(last_tx) - # Get transaction frequency metrics - frequency_stats = await db.fetchone( - """ - SELECT - COUNT(*) as total_transactions, - AVG(amount_sats) as avg_sats_per_tx, - AVG(amount_fiat) as avg_fiat_per_tx, - MIN(created_at) as first_tx, - MAX(created_at) as last_tx - FROM satmachineadmin.dca_payments - WHERE client_id = :client_id AND status = 'confirmed' - """, - {"client_id": client["id"]} - ) - - transaction_frequency = { - "total_transactions": frequency_stats["total_transactions"] if frequency_stats else 0, - "avg_sats_per_transaction": frequency_stats["avg_sats_per_tx"] if frequency_stats else 0, - "avg_fiat_per_transaction": frequency_stats["avg_fiat_per_tx"] if frequency_stats else 0, - "first_transaction": frequency_stats["first_tx"].isoformat() if frequency_stats and frequency_stats["first_tx"] else None, - "last_transaction": frequency_stats["last_tx"].isoformat() if frequency_stats and frequency_stats["last_tx"] else None - } - - return ClientAnalytics( - user_id=user_id, - cost_basis_history=cost_basis_history, - accumulation_timeline=accumulation_timeline, - transaction_frequency=transaction_frequency - ) + return ClientAnalytics( + user_id=user_id, + cost_basis_history=cost_basis_history, + accumulation_timeline=accumulation_timeline, + transaction_frequency=transaction_frequency + ) + + except Exception as e: + print(f"Error in get_client_analytics for user {user_id}: {str(e)}") + import traceback + traceback.print_exc() + return None async def get_client_by_user_id(user_id: str): diff --git a/static/js/index.js b/static/js/index.js index e61f6dc..d8ba3a5 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -51,7 +51,10 @@ window.app = Vue.createApp({ descending: true, page: 1, rowsPerPage: 10 - } + }, + chartTimeRange: '30d', + dcaChart: null, + analyticsData: null } }, @@ -81,14 +84,24 @@ window.app = Vue.createApp({ formatDate(dateString) { if (!dateString) return '' - return new Date(dateString).toLocaleDateString() + const date = new Date(dateString) + if (isNaN(date.getTime())) { + console.warn('Invalid date string:', dateString) + return 'Invalid Date' + } + return date.toLocaleDateString() }, formatTime(dateString) { if (!dateString) return '' - return new Date(dateString).toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit' + const date = new Date(dateString) + if (isNaN(date.getTime())) { + console.warn('Invalid time string:', dateString) + return 'Invalid Time' + } + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' }) }, @@ -104,7 +117,7 @@ window.app = Vue.createApp({ async loadDashboardData() { try { - const {data} = await LNbits.api.request( + const { data } = await LNbits.api.request( 'GET', '/satmachineclient/api/v1/dashboard/summary', this.g.user.wallets[0].inkey @@ -118,11 +131,19 @@ window.app = Vue.createApp({ async loadTransactions() { try { - const {data} = await LNbits.api.request( + const { data } = await LNbits.api.request( 'GET', '/satmachineclient/api/v1/dashboard/transactions?limit=50', this.g.user.wallets[0].inkey ) + + // Debug: Log the first transaction to see date format + if (data.length > 0) { + console.log('Sample transaction data:', data[0]) + console.log('transaction_time:', data[0].transaction_time) + console.log('created_at:', data[0].created_at) + } + // Sort by most recent first and store this.transactions = data.sort((a, b) => { const dateA = new Date(a.transaction_time || a.created_at) @@ -167,7 +188,7 @@ window.app = Vue.createApp({ getNextMilestone() { if (!this.dashboardData) return { target: 100000, name: '100k sats' } const sats = this.dashboardData.total_sats_accumulated - + if (sats < 10000) return { target: 10000, name: '10k sats' } if (sats < 100000) return { target: 100000, name: '100k sats' } if (sats < 500000) return { target: 500000, name: '500k sats' } @@ -180,10 +201,195 @@ window.app = Vue.createApp({ if (!this.dashboardData) return 0 const sats = this.dashboardData.total_sats_accumulated const milestone = this.getNextMilestone() - + // Show total progress toward the next milestone (from 0) const progress = (sats / milestone.target) * 100 return Math.min(Math.max(progress, 0), 100) + }, + async loadChartData() { + try { + const { data } = await LNbits.api.request( + 'GET', + `/satmachineclient/api/v1/dashboard/analytics?time_range=${this.chartTimeRange}`, + this.g.user.wallets[0].inkey + ) + + // Debug: Log analytics data + console.log('Analytics data received:', data) + if (data && data.cost_basis_history && data.cost_basis_history.length > 0) { + console.log('Sample cost basis point:', data.cost_basis_history[0]) + } + + this.analyticsData = data + // Use nextTick to ensure DOM is ready + this.$nextTick(() => { + this.initDCAChart() + }) + } catch (error) { + console.error('Error loading chart data:', error) + } + }, + + initDCAChart() { + console.log('initDCAChart called') + console.log('analyticsData:', this.analyticsData) + console.log('dcaChart ref:', this.$refs.dcaChart) + + if (!this.analyticsData) { + console.log('No analytics data available') + return + } + + if (!this.$refs.dcaChart) { + console.log('No chart ref available') + return + } + + // Check if Chart.js is loaded + if (typeof Chart === 'undefined') { + console.error('Chart.js is not loaded') + return + } + + // Destroy existing chart + if (this.dcaChart) { + this.dcaChart.destroy() + } + + const ctx = this.$refs.dcaChart.getContext('2d') + + // Use accumulation_timeline data which is already aggregated by day + const timelineData = this.analyticsData.accumulation_timeline || [] + + console.log('Timeline data:', timelineData) + console.log('Timeline data length:', timelineData.length) + + if (timelineData.length === 0) { + console.log('No timeline data available, falling back to cost basis data') + // Fallback to cost_basis_history if no timeline data + const costBasisData = this.analyticsData.cost_basis_history || [] + if (costBasisData.length === 0) { + console.log('No chart data available') + return + } + + // Group cost basis data by date to avoid duplicates + const groupedData = new Map() + costBasisData.forEach(point => { + const dateStr = new Date(point.date).toDateString() + if (!groupedData.has(dateStr)) { + groupedData.set(dateStr, point) + } else { + // Use the latest cumulative values for the same date + const existing = groupedData.get(dateStr) + if (point.cumulative_sats > existing.cumulative_sats) { + groupedData.set(dateStr, point) + } + } + }) + + const chartData = Array.from(groupedData.values()).sort((a, b) => + new Date(a.date).getTime() - new Date(b.date).getTime() + ) + + const labels = chartData.map(point => { + const date = new Date(point.date) + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + }) + }) + const cumulativeSats = chartData.map(point => point.cumulative_sats) + + this.createChart(labels, cumulativeSats) + return + } + + // Calculate running totals for timeline data + let runningSats = 0 + const labels = [] + const cumulativeSats = [] + + timelineData.forEach(point => { + runningSats += point.sats + + const date = new Date(point.date) + if (!isNaN(date.getTime())) { + labels.push(date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + })) + cumulativeSats.push(runningSats) + } + }) + + this.createChart(labels, cumulativeSats) + }, + + createChart(labels, cumulativeSats) { + const ctx = this.$refs.dcaChart.getContext('2d') + + this.dcaChart = new Chart(ctx, { + type: 'line', + data: { + labels: labels, + datasets: [{ + label: 'Total Sats Accumulated', + data: cumulativeSats, + borderColor: '#FF9500', // Bitcoin orange + backgroundColor: 'rgba(255, 149, 0, 0.1)', + borderWidth: 3, + fill: true, + tension: 0.4, + pointBackgroundColor: '#FF9500', + pointBorderColor: '#FF9500', + pointRadius: 4, + pointHoverRadius: 6 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false // Hide legend to keep it clean + }, + tooltip: { + mode: 'index', + intersect: false, + callbacks: { + label: function (context) { + return `${context.parsed.y.toLocaleString()} sats` + } + } + } + }, + scales: { + x: { + display: true, + grid: { + display: false + } + }, + y: { + display: true, + grid: { + color: 'rgba(0,0,0,0.1)' + }, + ticks: { + callback: function (value) { + return value.toLocaleString() + ' sats' + } + } + } + }, + interaction: { + mode: 'nearest', + axis: 'x', + intersect: false + } + } + }) } }, @@ -192,7 +398,8 @@ window.app = Vue.createApp({ this.loading = true await Promise.all([ this.loadDashboardData(), - this.loadTransactions() + this.loadTransactions(), + this.loadChartData() ]) } catch (error) { console.error('Error initializing dashboard:', error) @@ -202,6 +409,15 @@ window.app = Vue.createApp({ } }, + mounted() { + // Initialize chart after DOM is ready + this.$nextTick(() => { + if (this.analyticsData) { + this.initDCAChart() + } + }) + }, + computed: { hasData() { return this.dashboardData && !this.loading diff --git a/templates/satmachineclient/index.html b/templates/satmachineclient/index.html index 03fcd7c..1e89fb5 100644 --- a/templates/satmachineclient/index.html +++ b/templates/satmachineclient/index.html @@ -4,6 +4,7 @@ {% extends "base.html" %} {% from "macros.jinja" import window_vars with context %} {% block scripts %} {{ window_vars(user) }} + {% endblock %} {% block page %}