Compare commits

...

12 Commits

Author SHA1 Message Date
Ryan Chen
629f7effad Add disclaimer that fees/taxes/tips are not included
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:05:05 -04:00
Ryan Chen
07662efca8 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>
2026-03-13 18:03:37 -04:00
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 1240 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]]

774
app.py Normal file
View File

@@ -0,0 +1,774 @@
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">&#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>
</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>
<p style="font-size:12px; color:#8b7355; margin-bottom:24px; font-style:italic;">* Totals reflect item subtotals only. DoorDash fees, taxes, and tips are not included.</p>
<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;">&times;</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)

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" },
]