Compare commits

..

10 Commits

Author SHA1 Message Date
Ryan Chen
e28d71fa5a Rewrite as uv-based Flask web app with Dockerfile
- Flask dashboard with Plotly charts (stores, monthly, hourly, weekday, heatmap)
- Spotify Wrapped-style summary with year dropdown and inflation toggle
- CSV upload flow (ephemeral, no storage)
- Homepage with upload and saved data options
- Import script for loading CSVs into SQLite
- Dockerized with gunicorn for production

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:27:06 -04:00
Ryan Chen
ab5db9d645 small commit for refacotring 2023-08-29 21:18:50 -07:00
Ryan Chen
0dd3f56f7f yeet 2023-08-26 10:40:18 -07:00
Ryan Chen
15a9ea500e Added formatting 2023-08-26 10:37:47 -07:00
Ryan Chen
70f2747a35 added quote 2023-08-26 10:37:14 -07:00
Ryan Chen
5397ef29f8 Added function to import any Doordash CSV to create graphs 2023-08-21 21:19:56 -07:00
Ryan Chen
e6e0b43c4c Bad commit sadge 2023-08-21 21:07:42 -07:00
Ryan Chen
bfe5274896 Flipping the numpy array, better data 2023-08-20 10:00:39 -07:00
ryan
983c64fe85 Merge pull request 'ground breaking changes' (#1) from stephen/weakness:main into main
Reviewed-on: #1
2023-08-20 09:52:52 -07:00
0653e50814 ground breaking changes 2023-08-19 22:45:28 -04:00
11 changed files with 1057 additions and 19 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
.git
.venv
env
venv
__pycache__
*.pyc
.DS_Store
images
*.db
*.csv
*.db.bak

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
# who the fuck forgets to add this smh
env/
venv/
.venv/
__pycache__/
*.pyc
.DS_Store
*.db.bak

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.14

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM python:3.14-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY app.py import_csv.py ./
EXPOSE 8000
CMD ["uv", "run", "gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120"]

View File

@@ -2,4 +2,10 @@
Visualizing Akshay's moments of weakness. Visualizing Akshay's moments of weakness.
A tool to visualize your Doordash spending habits. To get the CSV required to use this repository, go to your Doordash settings and request your data. In around a day you will get an email with the CSV of your data. Use that with the tool and enjoy your horror.
## Testimonials
> That's not a moment of weakness. That's a life of weakness. - Noah
![[images/weaknessByDayWeek.png]] ![[images/weaknessByDayWeek.png]]

591
app.py Normal file
View File

@@ -0,0 +1,591 @@
import os
import sqlite3
import json
from datetime import datetime
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(datetime.fromisoformat(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 = datetime.fromisoformat(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",
"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 = datetime.fromisoformat(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 = datetime.fromisoformat(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" 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; }
.or-divider { color: #c4a96a; font-size: 13px; font-weight: 700; margin: 8px 0; }
</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">&#128196;</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 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>
<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="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('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', [{
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', 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');
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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 846 KiB

After

Width:  |  Height:  |  Size: 35 KiB

84
import_csv.py Normal file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Import a DoorDash CSV export into doordash.db."""
import csv
import sqlite3
import sys
from pathlib import Path
DB_PATH = Path(__file__).parent / "doordash.db"
CREATE_TABLE = """CREATE TABLE IF NOT EXISTS "doordash"(
"ITEM" TEXT, "CATEGORY" TEXT, "STORE_NAME" TEXT, "UNIT_PRICE" TEXT,
"QUANTITY" TEXT, "SUBTOTAL" TEXT, "CREATED_AT" TEXT, "DELIVERY_TIME" TEXT,
"DELIVERY_ADDRESS" TEXT
);"""
EXPECTED_HEADER = [
"ITEM", "CATEGORY", "STORE_NAME", "UNIT_PRICE", "QUANTITY",
"SUBTOTAL", "CREATED_AT", "DELIVERY_TIME", "DELIVERY_ADDRESS",
]
def import_csv(csv_path: str):
path = Path(csv_path)
if not path.exists():
print(f"Error: {csv_path} not found")
sys.exit(1)
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute(CREATE_TABLE)
# Get existing row count for reporting
before_count = c.execute("SELECT COUNT(*) FROM doordash").fetchone()[0]
# Check for duplicates using CREATED_AT + ITEM + STORE_NAME
existing = set()
for row in c.execute("SELECT ITEM, STORE_NAME, CREATED_AT FROM doordash"):
existing.add((row[0], row[1], row[2]))
imported = 0
skipped = 0
with open(path, newline="") as f:
reader = csv.reader(f)
header = next(reader)
# Validate header
header_upper = [h.strip().upper() for h in header]
if header_upper != EXPECTED_HEADER:
print(f"Error: unexpected CSV header.\nExpected: {EXPECTED_HEADER}\nGot: {header_upper}")
sys.exit(1)
for row in reader:
if len(row) != 9:
print(f"Warning: skipping row with {len(row)} columns: {row[:3]}...")
skipped += 1
continue
key = (row[0], row[2], row[6]) # ITEM, STORE_NAME, CREATED_AT
if key in existing:
skipped += 1
continue
c.execute("INSERT INTO doordash VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", row)
existing.add(key)
imported += 1
conn.commit()
after_count = c.execute("SELECT COUNT(*) FROM doordash").fetchone()[0]
conn.close()
print(f"Done! Imported {imported} rows, skipped {skipped} duplicates.")
print(f"Total rows: {before_count} -> {after_count}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <csv_file> [csv_file2 ...]")
sys.exit(1)
for csv_file in sys.argv[1:]:
print(f"\nImporting {csv_file}...")
import_csv(csv_file)

139
main.py
View File

@@ -1,10 +1,13 @@
import sqlite3 import sqlite3
import csv
import sys
from pprint import pprint from pprint import pprint
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import numpy as np import numpy as np
from datetime import datetime from datetime import datetime
from collections import defaultdict from collections import defaultdict
def weakness_by_hours(hours): def weakness_by_hours(hours):
keys = list(hours.keys()) keys = list(hours.keys())
keys.sort() keys.sort()
@@ -12,9 +15,8 @@ def weakness_by_hours(hours):
for key in keys: for key in keys:
print(key, len(hours.get(key))) print(key, len(hours.get(key)))
fig, ax = plt.subplots() fig, ax = plt.subplots()
ax.tick_params(axis='x', labelrotation=45) ax.tick_params(axis="x", labelrotation=45)
hour_list = keys hour_list = keys
counts = [len(hours.get(key)) for key in hours] counts = [len(hours.get(key)) for key in hours]
ax.bar(hour_list, counts) ax.bar(hour_list, counts)
@@ -22,6 +24,7 @@ def weakness_by_hours(hours):
ax.set_title("akshay's moments of weakness") ax.set_title("akshay's moments of weakness")
plt.show() plt.show()
class Weakness: class Weakness:
def __init__(self, row): def __init__(self, row):
self.name = row[0] self.name = row[0]
@@ -31,6 +34,7 @@ class Weakness:
self.time = datetime.today() self.time = datetime.today()
self.total = row[2] self.total = row[2]
def weaknessInAnHour(results): def weaknessInAnHour(results):
weaknesses = [Weakness(row) for row in results] weaknesses = [Weakness(row) for row in results]
restaurants = defaultdict(lambda: 0) restaurants = defaultdict(lambda: 0)
@@ -39,13 +43,12 @@ def weaknessInAnHour(results):
if weakness.time.hour == 0: if weakness.time.hour == 0:
restaurants[weakness.name] += 1 restaurants[weakness.name] += 1
print(restaurants)
fig, ax = plt.subplots() fig, ax = plt.subplots()
plt.xticks(rotation=45, ha='right') plt.xticks(rotation=45, ha="right")
ax.bar(restaurants.keys(), restaurants.values()) ax.bar(restaurants.keys(), restaurants.values())
plt.show() plt.show()
def weaknessPerMonth(results): def weaknessPerMonth(results):
weaknesses = [Weakness(row) for row in results] weaknesses = [Weakness(row) for row in results]
months = defaultdict(lambda: 0) months = defaultdict(lambda: 0)
@@ -59,6 +62,7 @@ def weaknessPerMonth(results):
ax.bar(sorted_keys, sorted_amounts) ax.bar(sorted_keys, sorted_amounts)
plt.show() plt.show()
def weaknessPerDayOverYear(results): def weaknessPerDayOverYear(results):
weaknesses = [Weakness(row) for row in results] weaknesses = [Weakness(row) for row in results]
months = defaultdict(lambda: 0) months = defaultdict(lambda: 0)
@@ -70,30 +74,129 @@ def weaknessPerDayOverYear(results):
day = weakness.time.isocalendar().weekday - 1 day = weakness.time.isocalendar().weekday - 1
week_array[week][day] += weakness.total week_array[week][day] += weakness.total
week_array = np.rot90(np.array(week_array)).round() week_array = np.rot90(np.array(week_array)).round().astype(int)
fig, ax = plt.subplots() fig, ax = plt.subplots()
im = ax.imshow(week_array, cmap="RdYlGn_r") im = ax.imshow(week_array, cmap="Reds")
ax.set_yticks(np.arange(7), labels=["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]) ax.set_yticks(
ax.set_xticks(np.arange(53), labels=[ f"Week {x+1}" for x in range(53)]) np.arange(7),
labels=[
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
],
)
ax.set_xticks(
np.arange(53),
labels=[
"",
"Jan",
"",
"",
"",
"Feb",
"",
"",
"",
"",
"Mar",
"",
"",
"",
"Apr",
"",
"",
"",
"",
"May",
"",
"",
"",
"Jun",
"",
"",
"",
"Jul",
"",
"",
"",
"Aug",
"",
"",
"",
"",
"Sep",
"",
"",
"",
"Oct",
"",
"",
"",
"",
"Nov",
"",
"",
"",
"Dec",
"",
"",
"",
],
)
# [ f"Week {x+1}" for x in range(53)])
plt.setp(ax.get_xticklabels(), rotation=45, ha="right", plt.setp(ax.get_xticklabels(), rotation=45, ha="right", rotation_mode="anchor")
rotation_mode="anchor")
for i in range(53): for i in range(53):
for j in range(7): for j in range(7):
text = ax.text(i, j, week_array[j, i], pass
ha="center", va="center", color="w", fontsize=5.0) # text = ax.text(i, j, week_array[j, i],
# ha="center", va="center", color="0", fontsize=12)
ax.set_title("akshay weakness in 2022") ax.set_title("akshay weakness in 2022")
fig.tight_layout() fig.tight_layout()
plt.show() plt.show()
def validateRow(row):
if len(row) != 9:
raise Exception("Should have 9 columns in Doordash data export")
float(row[3])
with sqlite3.connect("doordash.db") as connection: def importCSV(filename: str):
c = connection.cursor() CREATE_TABLE_COMMAND = """CREATE TABLE IF NOT EXISTS "doordash"(
results = c.execute("select STORE_NAME, DELIVERY_TIME, sum(cast(SUBTOTAL as decimal)) from doordash group by DELIVERY_TIME, STORE_NAME;") "ITEM" TEXT, "CATEGORY" TEXT, "STORE_NAME" TEXT, "UNIT_PRICE" TEXT,
"QUANTITY" TEXT, "SUBTOTAL" TEXT, "CREATED_AT" TEXT, "DELIVERY_TIME" TEXT,
"DELIVERY_ADDRESS" TEXT);"""
weaknessPerDayOverYear(results) csvReader = csv.reader(open("yeet.csv"), delimiter=",", quotechar='"')
filepart = filename.split(".")[0] + ".db"
conn = sqlite3.connect(filepart)
c = conn.cursor()
c.execute(CREATE_TABLE_COMMAND)
# Skip first row.
next(csvReader)
for row in csvReader:
validateRow(row)
c.execute("insert into doordash values (?, ?, ?, ?, ?, ?, ?, ?, ?)", row)
conn.commit()
if __name__ == "__main__":
if len(sys.argv) < 2:
raise Exception("input csv missing")
importCSV(sys.argv[1])

15
pyproject.toml Normal file
View File

@@ -0,0 +1,15 @@
[project]
name = "weakness"
version = "0.1.0"
description = "DoorDash spending dashboard"
requires-python = ">=3.14"
dependencies = [
"flask>=3.1.3",
"plotly>=6.6.0",
"numpy>=2.0",
"gunicorn>=25.1.0",
]
[project.scripts]
weakness = "app:app.run"
import-csv = "import_csv:main"

204
uv.lock generated Normal file
View File

@@ -0,0 +1,204 @@
version = 1
revision = 2
requires-python = ">=3.14"
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "flask"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
]
[[package]]
name = "gunicorn"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "narwhals"
version = "2.18.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/b4/02a8add181b8d2cd5da3b667cd102ae536e8c9572ab1a130816d70a89edb/narwhals-2.18.0.tar.gz", hash = "sha256:1de5cee338bc17c338c6278df2c38c0dd4290499fcf70d75e0a51d5f22a6e960", size = 620222, upload-time = "2026-03-10T15:51:27.14Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/75/0b4a10da17a44cf13567d08a9c7632a285297e46253263f1ae119129d10a/narwhals-2.18.0-py3-none-any.whl", hash = "sha256:68378155ee706ac9c5b25868ef62ecddd62947b6df7801a0a156bc0a615d2d0d", size = 444865, upload-time = "2026-03-10T15:51:24.085Z" },
]
[[package]]
name = "numpy"
version = "2.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" },
{ url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" },
{ url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" },
{ url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" },
{ url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" },
{ url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" },
{ url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" },
{ url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" },
{ url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" },
{ url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" },
{ url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" },
{ url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" },
{ url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" },
{ url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" },
{ url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" },
{ url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" },
{ url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" },
{ url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "plotly"
version = "6.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "narwhals" },
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/24/fb/41efe84970cfddefd4ccf025e2cbfafe780004555f583e93dba3dac2cdef/plotly-6.6.0.tar.gz", hash = "sha256:b897f15f3b02028d69f755f236be890ba950d0a42d7dfc619b44e2d8cea8748c", size = 7027956, upload-time = "2026-03-02T21:10:25.321Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/d2/c6e44dba74f17c6216ce1b56044a9b93a929f1c2d5bdaff892512b260f5e/plotly-6.6.0-py3-none-any.whl", hash = "sha256:8d6daf0f87412e0c0bfe72e809d615217ab57cc715899a1e5145135a7800d1d0", size = 9910315, upload-time = "2026-03-02T21:10:18.131Z" },
]
[[package]]
name = "weakness"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "flask" },
{ name = "gunicorn" },
{ name = "numpy" },
{ name = "plotly" },
]
[package.metadata]
requires-dist = [
{ name = "flask", specifier = ">=3.1.3" },
{ name = "gunicorn", specifier = ">=25.1.0" },
{ name = "numpy", specifier = ">=2.0" },
{ name = "plotly", specifier = ">=6.6.0" },
]
[[package]]
name = "werkzeug"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
]