update and fix

This commit is contained in:
padreug 2025-06-22 17:38:24 +02:00
parent c3adc37d84
commit 234daebef7
3 changed files with 473 additions and 115 deletions

324
crud.py
View file

@ -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):

View file

@ -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

View file

@ -4,6 +4,7 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="{{ static_url_for('satmachineclient/static', path='js/index.js') }}"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md" id="dcaClient">
@ -213,6 +214,33 @@
</q-card-section>
</q-card>
<!-- DCA Performance Chart -->
<q-card class="q-mb-md">
<q-card-section>
<h6 class="text-subtitle2 q-my-none q-mb-md">Bitcoin Accumulation Progress</h6>
<div class="chart-container" style="position: relative; height: 300px;">
<canvas ref="dcaChart" style="max-height: 300px;"></canvas>
</div>
<div class="row q-mt-sm">
<div class="col">
<q-btn-toggle
v-model="chartTimeRange"
@update:model-value="loadChartData"
toggle-color="orange"
:options="[
{label: '7D', value: '7d'},
{label: '30D', value: '30d'},
{label: '90D', value: '90d'},
{label: 'ALL', value: 'all'}
]"
size="sm"
flat
/>
</div>
</div>
</q-card-section>
</q-card>
<!-- Transaction History -->
<q-card>
<q-card-section>