Add weight tracker overlay and remove saved data shortcut
- Remove "View Saved Data" card from home page - Add weight tracker section with manual entry and CSV upload - Overlay weight as line on monthly spending chart (secondary y-axis) - Weight data stored in browser localStorage - Fix EST timezone handling for all timestamps - Fix JS string escaping issues in Jinja template Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
224
app.py
224
app.py
@@ -1,7 +1,17 @@
|
|||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
EST = timezone(timedelta(hours=-5))
|
||||||
|
|
||||||
|
|
||||||
|
def to_est(dt_str):
|
||||||
|
"""Parse ISO timestamp and convert to EST."""
|
||||||
|
dt = datetime.fromisoformat(dt_str)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(EST)
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
@@ -39,7 +49,7 @@ def compute_wrapped(subset_rows, inflation_adjust=False):
|
|||||||
sub = float(r["SUBTOTAL"])
|
sub = float(r["SUBTOTAL"])
|
||||||
if inflation_adjust:
|
if inflation_adjust:
|
||||||
try:
|
try:
|
||||||
yr = str(datetime.fromisoformat(r["CREATED_AT"]).year)
|
yr = str(to_est(r["CREATED_AT"]).year)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
yr = "2026"
|
yr = "2026"
|
||||||
sub *= CPI_TO_2026.get(yr, 1.0)
|
sub *= CPI_TO_2026.get(yr, 1.0)
|
||||||
@@ -47,7 +57,7 @@ def compute_wrapped(subset_rows, inflation_adjust=False):
|
|||||||
ss[r["STORE_NAME"]] += sub
|
ss[r["STORE_NAME"]] += sub
|
||||||
ic[r["ITEM"]] += 1
|
ic[r["ITEM"]] += 1
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromisoformat(r["CREATED_AT"])
|
dt = to_est(r["CREATED_AT"])
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
orders.add(r["CREATED_AT"])
|
orders.add(r["CREATED_AT"])
|
||||||
@@ -65,7 +75,7 @@ def compute_wrapped(subset_rows, inflation_adjust=False):
|
|||||||
"fav_store_name": fs[0],
|
"fav_store_name": fs[0],
|
||||||
"fav_store_total": f"${fs[1]:,.2f}",
|
"fav_store_total": f"${fs[1]:,.2f}",
|
||||||
"fav_store_pct": round(fs[1] / t_spent * 100, 1) if t_spent else 0,
|
"fav_store_pct": round(fs[1] / t_spent * 100, 1) if t_spent else 0,
|
||||||
"peak_hour_label": f"{ph[0]}:00",
|
"peak_hour_label": f"{ph[0]}:00 EST",
|
||||||
"peak_hour_count": ph[1],
|
"peak_hour_count": ph[1],
|
||||||
"peak_day_name": pd[0],
|
"peak_day_name": pd[0],
|
||||||
"biggest_month_label": datetime.strptime(bm[0], "%Y-%m").strftime("%B %Y"),
|
"biggest_month_label": datetime.strptime(bm[0], "%Y-%m").strftime("%B %Y"),
|
||||||
@@ -93,7 +103,7 @@ def render_dashboard(rows):
|
|||||||
for r in rows:
|
for r in rows:
|
||||||
store_spend[r["STORE_NAME"]] += float(r["SUBTOTAL"])
|
store_spend[r["STORE_NAME"]] += float(r["SUBTOTAL"])
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromisoformat(r["CREATED_AT"])
|
dt = to_est(r["CREATED_AT"])
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
total_orders_set.add(r["CREATED_AT"])
|
total_orders_set.add(r["CREATED_AT"])
|
||||||
@@ -108,7 +118,7 @@ def render_dashboard(rows):
|
|||||||
year_data = defaultdict(list)
|
year_data = defaultdict(list)
|
||||||
for r in rows:
|
for r in rows:
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromisoformat(r["CREATED_AT"])
|
dt = to_est(r["CREATED_AT"])
|
||||||
year_data[str(dt.year)].append(r)
|
year_data[str(dt.year)].append(r)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
@@ -128,7 +138,7 @@ def render_dashboard(rows):
|
|||||||
|
|
||||||
hour_labels = list(range(24))
|
hour_labels = list(range(24))
|
||||||
hour_values = [hour_counts.get(h, 0) for h in hour_labels]
|
hour_values = [hour_counts.get(h, 0) for h in hour_labels]
|
||||||
hour_labels_str = [f"{h}:00" for h in hour_labels]
|
hour_labels_str = [f"{h}:00 EST" for h in hour_labels]
|
||||||
|
|
||||||
day_order = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
day_order = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||||
weekday_values = [weekday_counts.get(d, 0) for d in day_order]
|
weekday_values = [weekday_counts.get(d, 0) for d in day_order]
|
||||||
@@ -275,7 +285,7 @@ HOME_TEMPLATE = """<!DOCTYPE html>
|
|||||||
.btn-primary:disabled { background: #aaa; cursor: not-allowed; }
|
.btn-primary:disabled { background: #aaa; cursor: not-allowed; }
|
||||||
.btn-outline { background: transparent; color: #3e7c17; border: 2px solid #3e7c17; }
|
.btn-outline { background: transparent; color: #3e7c17; border: 2px solid #3e7c17; }
|
||||||
.btn-outline:hover { background: #3e7c17; color: #fff; }
|
.btn-outline:hover { background: #3e7c17; color: #fff; }
|
||||||
.or-divider { color: #c4a96a; font-size: 13px; font-weight: 700; margin: 8px 0; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -305,13 +315,6 @@ HOME_TEMPLATE = """<!DOCTYPE html>
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="or-divider">or</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>View Saved Data</h2>
|
|
||||||
<p>Browse the dashboard with previously imported order history.</p>
|
|
||||||
<a href="/dashboard" class="btn btn-outline">Open Dashboard</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -464,6 +467,42 @@ TEMPLATE = """<!DOCTYPE html>
|
|||||||
<div id="wrapped-cards"></div>
|
<div id="wrapped-cards"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="wrapped" id="weight-section" style="background: linear-gradient(135deg, #4a9bb5, #7ecce5, #b5e8f5);">
|
||||||
|
<div class="wrapped-header">
|
||||||
|
<div>
|
||||||
|
<h2>Weight Tracker</h2>
|
||||||
|
<p class="tagline">See how your spending correlates with your weight</p>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px;">
|
||||||
|
<label style="display:flex; align-items:center; gap:6px; font-size:13px; font-weight:700; cursor:pointer;">
|
||||||
|
<input type="checkbox" id="weight-overlay-toggle" checked> Show on chart
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:12px; flex-wrap:wrap; align-items:end;">
|
||||||
|
<div class="wrap-card" style="flex:1; min-width:280px;">
|
||||||
|
<div style="display:flex; gap:8px; align-items:end;">
|
||||||
|
<div style="flex:1;">
|
||||||
|
<label style="font-size:12px; font-weight:700; opacity:0.85;">Date</label>
|
||||||
|
<input type="date" id="weight-date" style="width:100%; padding:8px; border-radius:8px; border:none; font-family:'Nunito',sans-serif; font-size:14px;">
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<label style="font-size:12px; font-weight:700; opacity:0.85;">Weight (lbs)</label>
|
||||||
|
<input type="number" id="weight-value" step="0.1" placeholder="170.0" style="width:100%; padding:8px; border-radius:8px; border:none; font-family:'Nunito',sans-serif; font-size:14px;">
|
||||||
|
</div>
|
||||||
|
<button onclick="addWeight()" style="padding:8px 16px; border-radius:8px; border:none; background:rgba(255,255,255,0.3); color:#fff; font-family:'Nunito',sans-serif; font-weight:800; font-size:14px; cursor:pointer;">Add</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center; margin-top:10px; padding-top:10px; border-top:1px solid rgba(255,255,255,0.2);">
|
||||||
|
<input type="file" id="weight-csv-input" accept=".csv" hidden>
|
||||||
|
<button onclick="document.getElementById('weight-csv-input').click()" style="padding:6px 14px; border-radius:8px; border:none; background:rgba(255,255,255,0.2); color:#fff; font-family:'Nunito',sans-serif; font-weight:700; font-size:13px; cursor:pointer;">Upload CSV</button>
|
||||||
|
<span style="font-size:11px; opacity:0.7;">Format: date,weight (e.g. 2025-01-15,172.3)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wrap-card" style="flex:1; min-width:280px; max-height:150px; overflow-y:auto;" id="weight-log">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
<div id="stores"></div>
|
<div id="stores"></div>
|
||||||
@@ -498,17 +537,12 @@ TEMPLATE = """<!DOCTYPE html>
|
|||||||
marker: { color: '#6abf69', line: { color: '#3e7c17', width: 1.5 } },
|
marker: { color: '#6abf69', line: { color: '#3e7c17', width: 1.5 } },
|
||||||
}], { ...layout_base, title: { text: 'Top Stores by Spend', font: { size: 16 } }, yaxis: { autorange: 'reversed' }, margin: { ...layout_base.margin, l: 180 } }, cfg);
|
}], { ...layout_base, title: { text: 'Top Stores by Spend', font: { size: 16 } }, yaxis: { autorange: 'reversed' }, margin: { ...layout_base.margin, l: 180 } }, cfg);
|
||||||
|
|
||||||
Plotly.newPlot('monthly', [{
|
|
||||||
x: {{ month_labels|safe }}, y: {{ month_totals|safe }},
|
|
||||||
type: 'bar',
|
|
||||||
marker: { color: '#7ecce5', line: { color: '#4a9bb5', width: 1.5 } },
|
|
||||||
}], { ...layout_base, title: { text: 'Monthly Spending', font: { size: 16 } } }, cfg);
|
|
||||||
|
|
||||||
Plotly.newPlot('hourly', [{
|
Plotly.newPlot('hourly', [{
|
||||||
x: {{ hour_labels|safe }}, y: {{ hour_values|safe }},
|
x: {{ hour_labels|safe }}, y: {{ hour_values|safe }},
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
marker: { color: '#f4c842', line: { color: '#c4a96a', width: 1.5 } },
|
marker: { color: '#f4c842', line: { color: '#c4a96a', width: 1.5 } },
|
||||||
}], { ...layout_base, title: { text: 'Orders by Hour', font: { size: 16 } } }, cfg);
|
}], { ...layout_base, title: { text: 'Orders by Hour (EST)', font: { size: 16 } } }, cfg);
|
||||||
|
|
||||||
Plotly.newPlot('weekday', [{
|
Plotly.newPlot('weekday', [{
|
||||||
x: {{ day_order|safe }}, y: {{ weekday_values|safe }},
|
x: {{ day_order|safe }}, y: {{ weekday_values|safe }},
|
||||||
@@ -571,6 +605,154 @@ TEMPLATE = """<!DOCTYPE html>
|
|||||||
inflationToggle.addEventListener('change', () => renderWrapped(yearSelect.value));
|
inflationToggle.addEventListener('change', () => renderWrapped(yearSelect.value));
|
||||||
renderWrapped('All Time');
|
renderWrapped('All Time');
|
||||||
|
|
||||||
|
// Weight tracking
|
||||||
|
const WEIGHT_KEY = 'weakness_weight_log';
|
||||||
|
function getWeights() {
|
||||||
|
return JSON.parse(localStorage.getItem(WEIGHT_KEY) || '[]');
|
||||||
|
}
|
||||||
|
function saveWeights(w) {
|
||||||
|
localStorage.setItem(WEIGHT_KEY, JSON.stringify(w));
|
||||||
|
}
|
||||||
|
function addWeight() {
|
||||||
|
const dateInput = document.getElementById('weight-date');
|
||||||
|
const valInput = document.getElementById('weight-value');
|
||||||
|
const d = dateInput.value;
|
||||||
|
const v = parseFloat(valInput.value);
|
||||||
|
if (!d || isNaN(v)) return;
|
||||||
|
const weights = getWeights();
|
||||||
|
// Remove existing entry for same date
|
||||||
|
const filtered = weights.filter(w => w.date !== d);
|
||||||
|
filtered.push({ date: d, weight: v });
|
||||||
|
filtered.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
saveWeights(filtered);
|
||||||
|
valInput.value = '';
|
||||||
|
renderWeightLog();
|
||||||
|
updateMonthlyChart();
|
||||||
|
}
|
||||||
|
document.getElementById('weight-csv-input').addEventListener('change', function(e) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function(ev) {
|
||||||
|
const lines = ev.target.result.trim().split(String.fromCharCode(10));
|
||||||
|
const weights = getWeights();
|
||||||
|
const existing = new Set(weights.map(w => w.date));
|
||||||
|
let added = 0;
|
||||||
|
for (const line of lines) {
|
||||||
|
const parts = line.split(',').map(s => s.trim());
|
||||||
|
if (parts.length < 2) continue;
|
||||||
|
const date = parts[0];
|
||||||
|
const val = parseFloat(parts[1]);
|
||||||
|
// Skip header rows or invalid data
|
||||||
|
if (!date.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/) || isNaN(val)) continue;
|
||||||
|
// Overwrite existing entry for same date
|
||||||
|
const idx = weights.findIndex(w => w.date === date);
|
||||||
|
if (idx >= 0) weights[idx].weight = val;
|
||||||
|
else weights.push({ date, weight: val });
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
weights.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
saveWeights(weights);
|
||||||
|
renderWeightLog();
|
||||||
|
updateMonthlyChart();
|
||||||
|
alert(`Imported ${added} weight entries.`);
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
e.target.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
function removeWeight(date) {
|
||||||
|
const weights = getWeights().filter(w => w.date !== date);
|
||||||
|
saveWeights(weights);
|
||||||
|
renderWeightLog();
|
||||||
|
updateMonthlyChart();
|
||||||
|
}
|
||||||
|
function renderWeightLog() {
|
||||||
|
const log = document.getElementById('weight-log');
|
||||||
|
const weights = getWeights();
|
||||||
|
if (!weights.length) {
|
||||||
|
log.innerHTML = '<div style="font-size:13px; opacity:0.85;">No weight entries yet. Add your first one!</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.innerHTML = weights.map(w =>
|
||||||
|
`<div style="display:flex; justify-content:space-between; align-items:center; padding:4px 0; border-bottom:1px solid rgba(255,255,255,0.15);">
|
||||||
|
<span style="font-size:13px; font-weight:700;">${w.date}: ${w.weight} lbs</span>
|
||||||
|
<button onclick="removeWeight('${w.date}')" style="background:none; border:none; color:rgba(255,255,255,0.7); cursor:pointer; font-size:16px; padding:0 4px;">×</button>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monthly chart with optional weight overlay
|
||||||
|
const monthLabels = {{ month_labels|safe }};
|
||||||
|
const monthTotals = {{ month_totals|safe }};
|
||||||
|
|
||||||
|
function updateMonthlyChart() {
|
||||||
|
const showWeight = document.getElementById('weight-overlay-toggle').checked;
|
||||||
|
const weights = getWeights();
|
||||||
|
|
||||||
|
// Aggregate weight by month (average per month)
|
||||||
|
const monthWeights = {};
|
||||||
|
const monthWeightCounts = {};
|
||||||
|
weights.forEach(w => {
|
||||||
|
const m = w.date.substring(0, 7); // YYYY-MM
|
||||||
|
if (!monthWeights[m]) { monthWeights[m] = 0; monthWeightCounts[m] = 0; }
|
||||||
|
monthWeights[m] += w.weight;
|
||||||
|
monthWeightCounts[m]++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build unified month list when weight overlay is on
|
||||||
|
const weightMonths = Object.keys(monthWeights).sort();
|
||||||
|
const allMonths = showWeight && weights.length > 0
|
||||||
|
? [...new Set([...monthLabels, ...weightMonths])].sort()
|
||||||
|
: monthLabels;
|
||||||
|
|
||||||
|
// Map spending to unified x-axis
|
||||||
|
const spendMap = {};
|
||||||
|
monthLabels.forEach((m, i) => { spendMap[m] = monthTotals[i]; });
|
||||||
|
const spendY = allMonths.map(m => spendMap[m] || 0);
|
||||||
|
|
||||||
|
const traces = [{
|
||||||
|
x: allMonths, y: spendY,
|
||||||
|
type: 'bar', name: 'Spending ($)',
|
||||||
|
marker: { color: '#7ecce5', line: { color: '#4a9bb5', width: 1.5 } },
|
||||||
|
}];
|
||||||
|
|
||||||
|
const layoutExtra = {};
|
||||||
|
|
||||||
|
if (showWeight && weights.length > 0) {
|
||||||
|
const weightY = allMonths.map(m => monthWeights[m] ? Math.round(monthWeights[m] / monthWeightCounts[m] * 10) / 10 : null);
|
||||||
|
|
||||||
|
traces.push({
|
||||||
|
x: allMonths, y: weightY,
|
||||||
|
type: 'scatter', mode: 'lines+markers', name: 'Weight (lbs)',
|
||||||
|
yaxis: 'y2',
|
||||||
|
line: { color: '#d4652a', width: 3 },
|
||||||
|
marker: { size: 7, color: '#d4652a' },
|
||||||
|
connectgaps: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
layoutExtra.yaxis2 = {
|
||||||
|
title: 'Weight (lbs)',
|
||||||
|
overlaying: 'y', side: 'right',
|
||||||
|
showgrid: false,
|
||||||
|
font: { color: '#d4652a' },
|
||||||
|
titlefont: { color: '#d4652a' },
|
||||||
|
tickfont: { color: '#d4652a' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Plotly.newPlot('monthly', traces, {
|
||||||
|
...layout_base,
|
||||||
|
title: { text: 'Monthly Spending' + (showWeight && weights.length ? ' & Weight' : ''), font: { size: 16 } },
|
||||||
|
...layoutExtra,
|
||||||
|
}, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('weight-overlay-toggle').addEventListener('change', updateMonthlyChart);
|
||||||
|
document.getElementById('weight-date').valueAsDate = new Date();
|
||||||
|
renderWeightLog();
|
||||||
|
updateMonthlyChart();
|
||||||
|
|
||||||
Plotly.newPlot('heatmap', [{
|
Plotly.newPlot('heatmap', [{
|
||||||
z: {{ heatmap_data|safe }}, y: {{ heatmap_days|safe }},
|
z: {{ heatmap_data|safe }}, y: {{ heatmap_days|safe }},
|
||||||
type: 'heatmap',
|
type: 'heatmap',
|
||||||
|
|||||||
Reference in New Issue
Block a user