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]: async def get_client_analytics(user_id: str, time_range: str = "30d") -> Optional[ClientAnalytics]:
"""Get client performance analytics""" """Get client performance analytics"""
# Get client ID try:
client = await db.fetchone( from datetime import datetime
"SELECT id FROM satmachineadmin.dca_clients WHERE user_id = :user_id",
{"user_id": user_id} # 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: # Calculate date range
return None if time_range == "7d":
start_date = datetime.now() - timedelta(days=7)
# Calculate date range elif time_range == "30d":
if time_range == "7d": start_date = datetime.now() - timedelta(days=30)
start_date = datetime.now() - timedelta(days=7) elif time_range == "90d":
elif time_range == "30d": start_date = datetime.now() - timedelta(days=90)
start_date = datetime.now() - timedelta(days=30) elif time_range == "1y":
elif time_range == "90d": start_date = datetime.now() - timedelta(days=365)
start_date = datetime.now() - timedelta(days=90) else: # "all"
elif time_range == "1y": start_date = datetime(2020, 1, 1) # Arbitrary early date
start_date = datetime.now() - timedelta(days=365)
else: # "all" # Get cost basis history (running average)
start_date = datetime(2020, 1, 1) # Arbitrary early date cost_basis_data = await db.fetchall(
"""
# Get cost basis history (running average) SELECT
cost_basis_data = await db.fetchall( COALESCE(transaction_time, created_at) as transaction_date,
""" amount_sats,
SELECT amount_fiat,
created_at, exchange_rate,
amount_sats, SUM(amount_sats) OVER (ORDER BY COALESCE(transaction_time, created_at)) as cumulative_sats,
amount_fiat, SUM(amount_fiat) OVER (ORDER BY COALESCE(transaction_time, created_at)) as cumulative_fiat
exchange_rate, FROM satmachineadmin.dca_payments
SUM(amount_sats) OVER (ORDER BY created_at) as cumulative_sats, WHERE client_id = :client_id
SUM(amount_fiat) OVER (ORDER BY created_at) as cumulative_fiat AND status = 'confirmed'
FROM satmachineadmin.dca_payments AND COALESCE(transaction_time, created_at) IS NOT NULL
WHERE client_id = :client_id AND COALESCE(transaction_time, created_at) >= :start_date
AND status = 'confirmed' ORDER BY COALESCE(transaction_time, created_at)
AND created_at >= :start_date """,
ORDER BY created_at {"client_id": client["id"], "start_date": start_date}
""", )
{"client_id": client["id"], "start_date": start_date}
) # Build cost basis history
cost_basis_history = []
# Build cost basis history for record in cost_basis_data:
cost_basis_history = [] avg_cost_basis = record["cumulative_sats"] / record["cumulative_fiat"] if record["cumulative_fiat"] > 0 else 0
for record in cost_basis_data: # Use transaction_date (which is COALESCE(transaction_time, created_at))
avg_cost_basis = record["cumulative_sats"] / record["cumulative_fiat"] if record["cumulative_fiat"] > 0 else 0 date_to_use = record["transaction_date"]
cost_basis_history.append({ if date_to_use is None:
"date": record["created_at"].isoformat(), print(f"Warning: Null date in cost basis data, skipping record")
"average_cost_basis": avg_cost_basis, continue
"cumulative_sats": record["cumulative_sats"], elif hasattr(date_to_use, 'isoformat'):
"cumulative_fiat": record["cumulative_fiat"] # This is a datetime object
}) date_str = date_to_use.isoformat()
elif hasattr(date_to_use, 'strftime'):
# Get accumulation timeline (daily/weekly aggregation) # This is a date object
accumulation_data = await db.fetchall( date_str = date_to_use.strftime('%Y-%m-%d')
""" elif isinstance(date_to_use, (int, float)):
SELECT # This might be a Unix timestamp - check if it's in a reasonable range
DATE(created_at) as date, timestamp = float(date_to_use)
SUM(amount_sats) as daily_sats, # Check if this looks like a timestamp (between 1970 and 2100)
SUM(amount_fiat) as daily_fiat, if 0 < timestamp < 4102444800: # Jan 1, 2100
COUNT(*) as daily_transactions # Could be seconds or milliseconds
FROM satmachineadmin.dca_payments if timestamp > 1000000000000: # Likely milliseconds
WHERE client_id = :client_id timestamp = timestamp / 1000
AND status = 'confirmed' date_str = datetime.fromtimestamp(timestamp).isoformat()
AND created_at >= :start_date else:
GROUP BY DATE(created_at) # Not a timestamp, treat as string
ORDER BY date date_str = str(date_to_use)
""", print(f"Warning: Numeric date value out of timestamp range: {date_to_use}")
{"client_id": client["id"], "start_date": start_date} 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)
accumulation_timeline = [ # Check if this looks like a timestamp
{ if 0 < timestamp < 4102444800: # Jan 1, 2100
"date": record["date"], # Could be seconds or milliseconds
"sats": record["daily_sats"], if timestamp > 1000000000000: # Likely milliseconds
"fiat": record["daily_fiat"], timestamp = timestamp / 1000
"transactions": record["daily_transactions"] 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 return ClientAnalytics(
frequency_stats = await db.fetchone( user_id=user_id,
""" cost_basis_history=cost_basis_history,
SELECT accumulation_timeline=accumulation_timeline,
COUNT(*) as total_transactions, transaction_frequency=transaction_frequency
AVG(amount_sats) as avg_sats_per_tx, )
AVG(amount_fiat) as avg_fiat_per_tx,
MIN(created_at) as first_tx, except Exception as e:
MAX(created_at) as last_tx print(f"Error in get_client_analytics for user {user_id}: {str(e)}")
FROM satmachineadmin.dca_payments import traceback
WHERE client_id = :client_id AND status = 'confirmed' traceback.print_exc()
""", return None
{"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
)
async def get_client_by_user_id(user_id: str): async def get_client_by_user_id(user_id: str):

View file

@ -51,7 +51,10 @@ window.app = Vue.createApp({
descending: true, descending: true,
page: 1, page: 1,
rowsPerPage: 10 rowsPerPage: 10
} },
chartTimeRange: '30d',
dcaChart: null,
analyticsData: null
} }
}, },
@ -81,14 +84,24 @@ window.app = Vue.createApp({
formatDate(dateString) { formatDate(dateString) {
if (!dateString) return '' 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) { formatTime(dateString) {
if (!dateString) return '' if (!dateString) return ''
return new Date(dateString).toLocaleTimeString('en-US', { const date = new Date(dateString)
hour: '2-digit', if (isNaN(date.getTime())) {
minute: '2-digit' 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() { async loadDashboardData() {
try { try {
const {data} = await LNbits.api.request( const { data } = await LNbits.api.request(
'GET', 'GET',
'/satmachineclient/api/v1/dashboard/summary', '/satmachineclient/api/v1/dashboard/summary',
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
@ -118,11 +131,19 @@ window.app = Vue.createApp({
async loadTransactions() { async loadTransactions() {
try { try {
const {data} = await LNbits.api.request( const { data } = await LNbits.api.request(
'GET', 'GET',
'/satmachineclient/api/v1/dashboard/transactions?limit=50', '/satmachineclient/api/v1/dashboard/transactions?limit=50',
this.g.user.wallets[0].inkey 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 // Sort by most recent first and store
this.transactions = data.sort((a, b) => { this.transactions = data.sort((a, b) => {
const dateA = new Date(a.transaction_time || a.created_at) const dateA = new Date(a.transaction_time || a.created_at)
@ -167,7 +188,7 @@ window.app = Vue.createApp({
getNextMilestone() { getNextMilestone() {
if (!this.dashboardData) return { target: 100000, name: '100k sats' } if (!this.dashboardData) return { target: 100000, name: '100k sats' }
const sats = this.dashboardData.total_sats_accumulated const sats = this.dashboardData.total_sats_accumulated
if (sats < 10000) return { target: 10000, name: '10k sats' } if (sats < 10000) return { target: 10000, name: '10k sats' }
if (sats < 100000) return { target: 100000, name: '100k sats' } if (sats < 100000) return { target: 100000, name: '100k sats' }
if (sats < 500000) return { target: 500000, name: '500k sats' } if (sats < 500000) return { target: 500000, name: '500k sats' }
@ -180,10 +201,195 @@ window.app = Vue.createApp({
if (!this.dashboardData) return 0 if (!this.dashboardData) return 0
const sats = this.dashboardData.total_sats_accumulated const sats = this.dashboardData.total_sats_accumulated
const milestone = this.getNextMilestone() const milestone = this.getNextMilestone()
// Show total progress toward the next milestone (from 0) // Show total progress toward the next milestone (from 0)
const progress = (sats / milestone.target) * 100 const progress = (sats / milestone.target) * 100
return Math.min(Math.max(progress, 0), 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 this.loading = true
await Promise.all([ await Promise.all([
this.loadDashboardData(), this.loadDashboardData(),
this.loadTransactions() this.loadTransactions(),
this.loadChartData()
]) ])
} catch (error) { } catch (error) {
console.error('Error initializing dashboard:', 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: { computed: {
hasData() { hasData() {
return this.dashboardData && !this.loading return this.dashboardData && !this.loading

View file

@ -4,6 +4,7 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context {% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }} %} {% 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> <script src="{{ static_url_for('satmachineclient/static', path='js/index.js') }}"></script>
{% endblock %} {% block page %} {% endblock %} {% block page %}
<div class="row q-col-gutter-md" id="dcaClient"> <div class="row q-col-gutter-md" id="dcaClient">
@ -213,6 +214,33 @@
</q-card-section> </q-card-section>
</q-card> </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 --> <!-- Transaction History -->
<q-card> <q-card>
<q-card-section> <q-card-section>