- 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>
774 lines
32 KiB
Python
774 lines
32 KiB
Python
import os
|
|
import sqlite3
|
|
import json
|
|
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
|
|
import csv
|
|
import io
|
|
from flask import Flask, render_template_string, request, redirect, url_for, flash
|
|
|
|
DB_PATH = os.environ.get("WEAKNESS_DB_PATH", "doordash.db")
|
|
|
|
# CPI-U annual average multipliers to 2026 dollars
|
|
CPI_TO_2026 = {
|
|
"2019": 1.28, "2020": 1.25, "2021": 1.18, "2022": 1.10,
|
|
"2023": 1.06, "2024": 1.03, "2025": 1.01, "2026": 1.00,
|
|
}
|
|
|
|
app = Flask(__name__)
|
|
app.secret_key = os.environ.get("SECRET_KEY", os.urandom(32))
|
|
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 # 16MB max upload
|
|
|
|
EXPECTED_HEADER = [
|
|
"ITEM", "CATEGORY", "STORE_NAME", "UNIT_PRICE", "QUANTITY",
|
|
"SUBTOTAL", "CREATED_AT", "DELIVERY_TIME", "DELIVERY_ADDRESS",
|
|
]
|
|
|
|
|
|
def compute_wrapped(subset_rows, inflation_adjust=False):
|
|
if not subset_rows:
|
|
return None
|
|
ss = defaultdict(float)
|
|
hc = defaultdict(int)
|
|
wc = defaultdict(int)
|
|
ms = defaultdict(float)
|
|
ic = defaultdict(int)
|
|
orders = set()
|
|
t_spent = 0.0
|
|
for r in subset_rows:
|
|
sub = float(r["SUBTOTAL"])
|
|
if inflation_adjust:
|
|
try:
|
|
yr = str(to_est(r["CREATED_AT"]).year)
|
|
except (ValueError, TypeError):
|
|
yr = "2026"
|
|
sub *= CPI_TO_2026.get(yr, 1.0)
|
|
t_spent += sub
|
|
ss[r["STORE_NAME"]] += sub
|
|
ic[r["ITEM"]] += 1
|
|
try:
|
|
dt = to_est(r["CREATED_AT"])
|
|
except (ValueError, TypeError):
|
|
continue
|
|
orders.add(r["CREATED_AT"])
|
|
hc[dt.hour] += 1
|
|
wc[dt.strftime("%A")] += 1
|
|
ms[dt.strftime("%Y-%m")] += sub
|
|
|
|
fs = max(ss.items(), key=lambda x: x[1])
|
|
ph = max(hc.items(), key=lambda x: x[1])
|
|
pd = max(wc.items(), key=lambda x: x[1])
|
|
bm = max(ms.items(), key=lambda x: x[1])
|
|
fi = max(ic.items(), key=lambda x: x[1])
|
|
avg = round(t_spent / len(orders), 2) if orders else 0
|
|
return {
|
|
"fav_store_name": fs[0],
|
|
"fav_store_total": f"${fs[1]:,.2f}",
|
|
"fav_store_pct": round(fs[1] / t_spent * 100, 1) if t_spent else 0,
|
|
"peak_hour_label": f"{ph[0]}:00 EST",
|
|
"peak_hour_count": ph[1],
|
|
"peak_day_name": pd[0],
|
|
"biggest_month_label": datetime.strptime(bm[0], "%Y-%m").strftime("%B %Y"),
|
|
"biggest_month_total": f"${bm[1]:,.2f}",
|
|
"avg_per_order": f"${avg:,.2f}",
|
|
"fav_item_name": fi[0],
|
|
"fav_item_count": fi[1],
|
|
"total_spent": f"${t_spent:,.2f}",
|
|
"total_items": len(subset_rows),
|
|
"total_orders": len(orders),
|
|
}
|
|
|
|
|
|
def render_dashboard(rows):
|
|
import numpy as np
|
|
|
|
total_spent = sum(float(r["SUBTOTAL"]) for r in rows)
|
|
total_orders_set = set()
|
|
store_spend = defaultdict(float)
|
|
month_spend = defaultdict(float)
|
|
hour_counts = defaultdict(int)
|
|
weekday_counts = defaultdict(int)
|
|
week_grid = [[0] * 7 for _ in range(53)]
|
|
|
|
for r in rows:
|
|
store_spend[r["STORE_NAME"]] += float(r["SUBTOTAL"])
|
|
try:
|
|
dt = to_est(r["CREATED_AT"])
|
|
except (ValueError, TypeError):
|
|
continue
|
|
total_orders_set.add(r["CREATED_AT"])
|
|
month_key = dt.strftime("%Y-%m")
|
|
month_spend[month_key] += float(r["SUBTOTAL"])
|
|
hour_counts[dt.hour] += 1
|
|
weekday_counts[dt.strftime("%A")] += 1
|
|
week = min(dt.isocalendar().week - 1, 52)
|
|
day = dt.isocalendar().weekday - 1
|
|
week_grid[week][day] += float(r["SUBTOTAL"])
|
|
|
|
year_data = defaultdict(list)
|
|
for r in rows:
|
|
try:
|
|
dt = to_est(r["CREATED_AT"])
|
|
year_data[str(dt.year)].append(r)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
wrapped_by_year = {"All Time": compute_wrapped(rows)}
|
|
wrapped_by_year_adj = {"All Time": compute_wrapped(rows, inflation_adjust=True)}
|
|
for year in sorted(year_data.keys()):
|
|
wrapped_by_year[year] = compute_wrapped(year_data[year])
|
|
wrapped_by_year_adj[year] = compute_wrapped(year_data[year], inflation_adjust=True)
|
|
|
|
top_stores = sorted(store_spend.items(), key=lambda x: x[1], reverse=True)[:15]
|
|
store_names = [s[0] for s in top_stores]
|
|
store_totals = [round(s[1], 2) for s in top_stores]
|
|
|
|
sorted_months = sorted(month_spend.keys())
|
|
month_totals = [round(month_spend[m], 2) for m in sorted_months]
|
|
|
|
hour_labels = list(range(24))
|
|
hour_values = [hour_counts.get(h, 0) 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"]
|
|
weekday_values = [weekday_counts.get(d, 0) for d in day_order]
|
|
|
|
grid = np.array(week_grid)
|
|
heatmap_data = np.rot90(grid).tolist()
|
|
heatmap_days = ["Sun", "Sat", "Fri", "Thu", "Wed", "Tue", "Mon"]
|
|
|
|
return render_template_string(
|
|
TEMPLATE,
|
|
total_spent=f"${total_spent:,.2f}",
|
|
total_items=len(rows),
|
|
total_orders=len(total_orders_set),
|
|
store_names=json.dumps(store_names),
|
|
store_totals=json.dumps(store_totals),
|
|
month_labels=json.dumps(sorted_months),
|
|
month_totals=json.dumps(month_totals),
|
|
hour_labels=json.dumps(hour_labels_str),
|
|
hour_values=json.dumps(hour_values),
|
|
day_order=json.dumps(day_order),
|
|
weekday_values=json.dumps(weekday_values),
|
|
heatmap_data=json.dumps(heatmap_data),
|
|
heatmap_days=json.dumps(heatmap_days),
|
|
wrapped_data=json.dumps(wrapped_by_year),
|
|
wrapped_data_adj=json.dumps(wrapped_by_year_adj),
|
|
)
|
|
|
|
|
|
def parse_csv_to_rows(text):
|
|
reader = csv.reader(io.StringIO(text))
|
|
header = [h.strip().upper() for h in next(reader)]
|
|
if header != EXPECTED_HEADER:
|
|
return None, "Invalid CSV header"
|
|
rows = []
|
|
for row in reader:
|
|
if len(row) != 9:
|
|
continue
|
|
rows.append({
|
|
"ITEM": row[0], "CATEGORY": row[1], "STORE_NAME": row[2],
|
|
"UNIT_PRICE": row[3], "QUANTITY": row[4], "SUBTOTAL": row[5],
|
|
"CREATED_AT": row[6], "DELIVERY_TIME": row[7], "DELIVERY_ADDRESS": row[8],
|
|
})
|
|
return rows, None
|
|
|
|
|
|
@app.route("/")
|
|
def home():
|
|
return render_template_string(HOME_TEMPLATE)
|
|
|
|
|
|
@app.route("/dashboard")
|
|
def dashboard():
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
rows = conn.execute(
|
|
"SELECT ITEM, STORE_NAME, UNIT_PRICE, QUANTITY, SUBTOTAL, CREATED_AT FROM doordash"
|
|
).fetchall()
|
|
conn.close()
|
|
return render_dashboard(rows)
|
|
|
|
|
|
@app.route("/upload", methods=["GET", "POST"])
|
|
def upload():
|
|
if request.method == "POST":
|
|
file = request.files.get("csv_file")
|
|
if not file or not file.filename.endswith(".csv"):
|
|
flash("Please upload a .csv file", "error")
|
|
return redirect(url_for("home"))
|
|
|
|
text = file.stream.read().decode("utf-8")
|
|
rows, err = parse_csv_to_rows(text)
|
|
if err:
|
|
flash(err, "error")
|
|
return redirect(url_for("home"))
|
|
|
|
return render_dashboard(rows)
|
|
|
|
return redirect(url_for("home"))
|
|
|
|
|
|
HOME_TEMPLATE = """<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Moments of Weakness</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap" rel="stylesheet">
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: 'Nunito', sans-serif;
|
|
background: #f9f3e3;
|
|
color: #5c4a32;
|
|
min-height: 100vh;
|
|
display: flex; justify-content: center; align-items: center;
|
|
background-image:
|
|
radial-gradient(circle at 20% 80%, #e8f5e9 0%, transparent 40%),
|
|
radial-gradient(circle at 80% 20%, #fff8e1 0%, transparent 40%);
|
|
}
|
|
.home {
|
|
text-align: center;
|
|
max-width: 560px; width: 100%;
|
|
padding: 24px;
|
|
}
|
|
h1 { font-size: 42px; color: #3e7c17; font-weight: 800; margin-bottom: 8px; }
|
|
.tagline { color: #8b7355; font-size: 16px; margin-bottom: 40px; }
|
|
.flash { padding: 10px 16px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; font-weight: 700; }
|
|
.flash.error { background: #fde8e8; color: #c0392b; border: 1px solid #e6b0aa; }
|
|
.cards { display: flex; flex-direction: column; gap: 16px; }
|
|
.card {
|
|
background: #fffef5;
|
|
border: 3px solid #c4a96a;
|
|
border-radius: 20px;
|
|
padding: 32px;
|
|
box-shadow: 2px 3px 0 #c4a96a;
|
|
transition: transform 0.15s, box-shadow 0.15s;
|
|
}
|
|
.card:hover { transform: translateY(-2px); box-shadow: 2px 5px 0 #c4a96a; }
|
|
.card h2 { font-size: 20px; color: #3e7c17; margin-bottom: 6px; }
|
|
.card p { font-size: 14px; color: #8b7355; margin-bottom: 16px; }
|
|
|
|
.drop-zone {
|
|
border: 3px dashed #c4a96a;
|
|
border-radius: 14px;
|
|
padding: 32px 20px;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
margin-bottom: 16px;
|
|
}
|
|
.drop-zone:hover, .drop-zone.dragover { background: #f5eed8; }
|
|
.drop-zone .icon { font-size: 36px; margin-bottom: 6px; }
|
|
.drop-zone .label { font-size: 14px; font-weight: 700; color: #5c4a32; }
|
|
.drop-zone .sublabel { font-size: 12px; color: #8b7355; margin-top: 4px; }
|
|
.file-name { font-size: 13px; color: #3e7c17; font-weight: 700; margin-bottom: 12px; display: none; }
|
|
|
|
.btn {
|
|
font-family: 'Nunito', sans-serif;
|
|
font-size: 15px; font-weight: 800;
|
|
border: none; border-radius: 12px;
|
|
padding: 12px 32px;
|
|
cursor: pointer; transition: background 0.2s;
|
|
text-decoration: none; display: inline-block;
|
|
}
|
|
.btn-primary { background: #3e7c17; color: #fff; }
|
|
.btn-primary:hover { background: #2d6b1e; }
|
|
.btn-primary:disabled { background: #aaa; cursor: not-allowed; }
|
|
.btn-outline { background: transparent; color: #3e7c17; border: 2px solid #3e7c17; }
|
|
.btn-outline:hover { background: #3e7c17; color: #fff; }
|
|
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="home">
|
|
<h1>Moments of Weakness</h1>
|
|
<p class="tagline">See exactly how much you've been spending on delivery</p>
|
|
|
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
{% for cat, msg in messages %}
|
|
<div class="flash {{ cat }}">{{ msg }}</div>
|
|
{% endfor %}
|
|
{% endwith %}
|
|
|
|
<div class="cards">
|
|
<div class="card">
|
|
<h2>Upload Your Data</h2>
|
|
<p>Drop your DoorDash CSV export to see your dashboard. Nothing is stored.</p>
|
|
<form method="POST" action="/upload" enctype="multipart/form-data" id="upload-form">
|
|
<div class="drop-zone" id="drop-zone">
|
|
<div class="icon">📄</div>
|
|
<div class="label">Click or drag a CSV file here</div>
|
|
<div class="sublabel">consumer_order_details.csv from DoorDash</div>
|
|
</div>
|
|
<input type="file" name="csv_file" id="csv-input" accept=".csv" hidden>
|
|
<div class="file-name" id="file-name"></div>
|
|
<button type="submit" class="btn btn-primary" id="submit-btn" disabled>View Dashboard</button>
|
|
</form>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const dropZone = document.getElementById('drop-zone');
|
|
const input = document.getElementById('csv-input');
|
|
const fileName = document.getElementById('file-name');
|
|
const submitBtn = document.getElementById('submit-btn');
|
|
|
|
dropZone.addEventListener('click', () => input.click());
|
|
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
|
|
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
|
|
dropZone.addEventListener('drop', e => {
|
|
e.preventDefault(); dropZone.classList.remove('dragover');
|
|
if (e.dataTransfer.files.length) { input.files = e.dataTransfer.files; showFile(); }
|
|
});
|
|
input.addEventListener('change', showFile);
|
|
|
|
function showFile() {
|
|
if (input.files.length) {
|
|
fileName.textContent = input.files[0].name;
|
|
fileName.style.display = 'block';
|
|
submitBtn.disabled = false;
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
TEMPLATE = """<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Weakness Dashboard</title>
|
|
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
|
|
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap" rel="stylesheet">
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: 'Nunito', sans-serif;
|
|
background: #f9f3e3;
|
|
color: #5c4a32;
|
|
padding: 24px;
|
|
background-image:
|
|
radial-gradient(circle at 20% 80%, #e8f5e9 0%, transparent 40%),
|
|
radial-gradient(circle at 80% 20%, #fff8e1 0%, transparent 40%);
|
|
}
|
|
h1 { font-size: 32px; margin-bottom: 4px; color: #3e7c17; font-weight: 800; }
|
|
.subtitle { color: #8b7355; margin-bottom: 24px; font-size: 14px; }
|
|
.stats { display: flex; gap: 16px; margin-bottom: 24px; }
|
|
.stat {
|
|
background: #fffef5;
|
|
border: 3px solid #c4a96a;
|
|
border-radius: 16px;
|
|
padding: 16px 24px;
|
|
flex: 1;
|
|
box-shadow: 2px 3px 0 #c4a96a;
|
|
}
|
|
.stat .value { font-size: 28px; font-weight: 800; color: #d4652a; }
|
|
.stat .label { color: #8b7355; font-size: 13px; margin-top: 4px; font-weight: 700; }
|
|
.wrapped {
|
|
background: linear-gradient(135deg, #d4652a, #e88b6a, #f4c842);
|
|
border-radius: 20px;
|
|
padding: 32px;
|
|
margin-bottom: 24px;
|
|
color: #fff;
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 20px;
|
|
}
|
|
.wrapped {
|
|
display: flex; flex-direction: column; gap: 12px;
|
|
}
|
|
.wrapped-header {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
}
|
|
.wrapped h2 { font-size: 24px; font-weight: 800; margin-bottom: 2px; }
|
|
.wrapped .tagline { font-size: 14px; opacity: 0.85; }
|
|
#year-select {
|
|
font-family: 'Nunito', sans-serif; font-size: 15px; font-weight: 700;
|
|
background: rgba(255,255,255,0.2); color: #fff; border: 2px solid rgba(255,255,255,0.4);
|
|
border-radius: 10px; padding: 8px 14px; cursor: pointer; appearance: auto;
|
|
}
|
|
#year-select option { color: #333; background: #fff; }
|
|
#wrapped-cards {
|
|
display: flex; flex-direction: column; gap: 12px;
|
|
}
|
|
.wrap-card {
|
|
background: rgba(255,255,255,0.15);
|
|
backdrop-filter: blur(4px);
|
|
border-radius: 14px;
|
|
padding: 20px;
|
|
}
|
|
.wrap-card .big { font-size: 22px; font-weight: 800; line-height: 1.2; }
|
|
.wrap-card .note { font-size: 13px; opacity: 0.85; margin-top: 6px; }
|
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
|
|
.chart-card {
|
|
background: #fffef5;
|
|
border: 3px solid #c4a96a;
|
|
border-radius: 16px;
|
|
padding: 16px;
|
|
min-height: 400px;
|
|
box-shadow: 2px 3px 0 #c4a96a;
|
|
}
|
|
.full-width { grid-column: 1 / -1; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
|
|
<h1>Moments of Weakness</h1>
|
|
<a href="/" style="font-size:14px; font-weight:700; color:#3e7c17; text-decoration:none; background:#fffef5; border:2px solid #c4a96a; border-radius:10px; padding:8px 16px;">Home</a>
|
|
</div>
|
|
<p class="subtitle">DoorDash spending dashboard</p>
|
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
{% for cat, msg in messages %}
|
|
<div style="padding:10px 16px; border-radius:8px; margin-bottom:16px; font-size:14px; font-weight:700;
|
|
{% if cat == 'success' %}background:#e8f5e9; color:#27ae60; border:1px solid #a9dfbf;
|
|
{% else %}background:#fde8e8; color:#c0392b; border:1px solid #e6b0aa;{% endif %}">{{ msg }}</div>
|
|
{% endfor %}
|
|
{% endwith %}
|
|
|
|
<div class="stats">
|
|
<div class="stat">
|
|
<div class="value">{{ total_spent }}</div>
|
|
<div class="label">Total Spent</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="value">{{ total_items }}</div>
|
|
<div class="label">Items Ordered</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="value">{{ total_orders }}</div>
|
|
<div class="label">Unique Orders</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="wrapped">
|
|
<div class="wrapped-header">
|
|
<div>
|
|
<h2>Your Weakness Wrapped</h2>
|
|
<p class="tagline">A look at your most questionable financial decisions</p>
|
|
</div>
|
|
<div style="display:flex; align-items:center; gap:16px;">
|
|
<label style="display:flex; align-items:center; gap:6px; font-size:13px; font-weight:700; cursor:pointer;">
|
|
<input type="checkbox" id="inflation-toggle"> Inflation adjusted (2026 $)
|
|
</label>
|
|
<select id="year-select"></select>
|
|
</div>
|
|
</div>
|
|
<div id="wrapped-cards"></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="chart-card">
|
|
<div id="stores"></div>
|
|
</div>
|
|
<div class="chart-card">
|
|
<div id="monthly"></div>
|
|
</div>
|
|
<div class="chart-card">
|
|
<div id="hourly"></div>
|
|
</div>
|
|
<div class="chart-card">
|
|
<div id="weekday"></div>
|
|
</div>
|
|
<div class="chart-card full-width">
|
|
<div id="heatmap"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const bg = '#fffef5';
|
|
const layout_base = {
|
|
paper_bgcolor: bg, plot_bgcolor: bg,
|
|
font: { family: 'Nunito', color: '#5c4a32', size: 12 },
|
|
margin: { t: 44, b: 60, l: 60, r: 20 },
|
|
height: 370,
|
|
};
|
|
const cfg = { responsive: true };
|
|
|
|
Plotly.newPlot('stores', [{
|
|
x: {{ store_totals|safe }}, y: {{ store_names|safe }},
|
|
type: 'bar', orientation: 'h',
|
|
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);
|
|
|
|
|
|
Plotly.newPlot('hourly', [{
|
|
x: {{ hour_labels|safe }}, y: {{ hour_values|safe }},
|
|
type: 'bar',
|
|
marker: { color: '#f4c842', line: { color: '#c4a96a', width: 1.5 } },
|
|
}], { ...layout_base, title: { text: 'Orders by Hour (EST)', font: { size: 16 } } }, cfg);
|
|
|
|
Plotly.newPlot('weekday', [{
|
|
x: {{ day_order|safe }}, y: {{ weekday_values|safe }},
|
|
type: 'bar',
|
|
marker: { color: '#e88b6a', line: { color: '#c46a4a', width: 1.5 } },
|
|
}], { ...layout_base, title: { text: 'Orders by Day of Week', font: { size: 16 } } }, cfg);
|
|
|
|
// Wrapped
|
|
const wrappedRaw = {{ wrapped_data|safe }};
|
|
const wrappedAdj = {{ wrapped_data_adj|safe }};
|
|
const yearSelect = document.getElementById('year-select');
|
|
const inflationToggle = document.getElementById('inflation-toggle');
|
|
const cardsDiv = document.getElementById('wrapped-cards');
|
|
|
|
Object.keys(wrappedRaw).forEach(key => {
|
|
const opt = document.createElement('option');
|
|
opt.value = key; opt.textContent = key;
|
|
yearSelect.appendChild(opt);
|
|
});
|
|
|
|
function renderWrapped(key) {
|
|
const src = inflationToggle.checked ? wrappedAdj : wrappedRaw;
|
|
const d = src[key];
|
|
if (!d) { cardsDiv.innerHTML = ''; return; }
|
|
cardsDiv.innerHTML = `
|
|
<div style="display:flex; gap:12px;">
|
|
<div class="wrap-card" style="flex:1; text-align:center;">
|
|
<div class="big">${d.total_spent}</div>
|
|
<div class="note">Total spent${key === 'All Time' ? '' : ' in ' + key}</div>
|
|
</div>
|
|
<div class="wrap-card" style="flex:1; text-align:center;">
|
|
<div class="big">${d.total_orders}</div>
|
|
<div class="note">Orders placed${key === 'All Time' ? '' : ' in ' + key}</div>
|
|
</div>
|
|
</div>
|
|
<div class="wrap-card">
|
|
<div class="big">${d.fav_store_name}</div>
|
|
<div class="note">Your #1 store. You gave them ${d.fav_store_total} (${d.fav_store_pct}% of spending). They should name a menu item after you.</div>
|
|
</div>
|
|
<div class="wrap-card">
|
|
<div class="big">${d.fav_item_name}</div>
|
|
<div class="note">Ordered ${d.fav_item_count} times. At this point it's not a craving, it's a personality trait.</div>
|
|
</div>
|
|
<div class="wrap-card">
|
|
<div class="big">${d.peak_hour_label} on ${d.peak_day_name}s</div>
|
|
<div class="note">Your peak ordering time. ${d.peak_hour_count} orders placed at this hour. The delivery driver knows your face.</div>
|
|
</div>
|
|
<div class="wrap-card">
|
|
<div class="big">${d.biggest_month_label}</div>
|
|
<div class="note">Your weakest month at ${d.biggest_month_total}. Whatever happened that month... we hope you're okay.</div>
|
|
</div>
|
|
<div class="wrap-card" style="text-align: center;">
|
|
<div class="big">${d.avg_per_order} per order</div>
|
|
<div class="note">Your average order total. That's a lot of "I'll just get something small."</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
yearSelect.addEventListener('change', () => renderWrapped(yearSelect.value));
|
|
inflationToggle.addEventListener('change', () => renderWrapped(yearSelect.value));
|
|
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', [{
|
|
z: {{ heatmap_data|safe }}, y: {{ heatmap_days|safe }},
|
|
type: 'heatmap',
|
|
colorscale: [[0, '#fffef5'], [0.25, '#d4e8c2'], [0.5, '#8fc46a'], [0.75, '#4a9b3e'], [1, '#2d6b1e']],
|
|
colorbar: { title: '$' },
|
|
}], { ...layout_base, title: { text: 'Weekly Spending Heatmap', font: { size: 16 } }, height: 300, margin: { ...layout_base.margin, l: 60 } }, cfg);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
if __name__ == "__main__":
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("-p", "--port", type=int, default=5000)
|
|
args = parser.parse_args()
|
|
app.run(debug=True, port=args.port)
|