Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions PROJECT_SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ RunRush utilizes a monolithic architecture with server-side rendering, enhanced
### Internal API Endpoints (JSON)
* `/add` (POST) - Logs a new manual run.
* `/edit/<run_id>`, `/delete/<run_id>` - Run modifications.
* `/api/runs` (GET) - Returns paginated, filtered, and sorted runs for the progressive dashboard list.
* `/api/parse-import` (POST) - Parses uploaded Strava CSVs.
* `/api/parse-screenshot` (POST) - Sends image to Google GenAI for OCR run data extraction.
* `/api/heatmap-data` (GET) - Returns GeoJSON/Coordinate data for Leaflet maps.
Expand Down
96 changes: 96 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2102,6 +2102,102 @@ def update_settings():

# ---------- MONTHLY PROGRESS ----------


@app.route("/api/runs")
def api_runs():
if not require_login():
return jsonify({"error": "Unauthorized"}), 401
user = get_current_user()

sort_by = request.args.get("sort", "date")
filter_opt = request.args.get("filter", "all")
try:
offset = int(request.args.get("offset", 0))
limit = int(request.args.get("limit", 15))
if offset < 0 or limit < 0:
raise ValueError()
limit = min(limit, 50) # safe upper bound
except ValueError:
return jsonify({"error": "Invalid offset or limit"}), 400

conn = get_db()

base_query = "SELECT * FROM runs WHERE user_id = ?"

if sort_by == "distance_asc":
order_clause = " ORDER BY distance_km ASC, date DESC"
elif sort_by == "distance" or sort_by == "distance_desc":
order_clause = " ORDER BY distance_km DESC, date DESC"
elif sort_by == "time_asc":
order_clause = " ORDER BY time_min ASC, date DESC"
elif sort_by == "time" or sort_by == "time_desc":
order_clause = " ORDER BY time_min DESC, date DESC"
elif sort_by == "pace_asc":
order_clause = " ORDER BY pace ASC, date DESC"
elif sort_by == "pace" or sort_by == "pace_desc":
order_clause = " ORDER BY pace DESC, date DESC"
elif sort_by == "cal_asc":
order_clause = " ORDER BY calories ASC, date DESC"
elif sort_by == "cal" or sort_by == "cal_desc":
order_clause = " ORDER BY calories DESC, date DESC"
elif sort_by == "date_asc":
order_clause = " ORDER BY date ASC, id ASC"
else: # default = date or date_desc
order_clause = " ORDER BY date DESC, id DESC"

runs = conn.execute(base_query + order_clause, (user["id"],)).fetchall()

# Filtering (Must match main route logic)
filtered_runs = list(runs)
today = get_today()

if filter_opt == "last7":
cutoff = today - timedelta(days=7)
temp = []
for r in runs:
try:
d = datetime.strptime(str(r["date"])[:10], "%Y-%m-%d").date()
if d >= cutoff:
temp.append(r)
except Exception:
continue
filtered_runs = temp

elif filter_opt == "month":
current_year = today.year
current_month = today.month
month_runs = []
for r in runs:
try:
d = datetime.strptime(str(r["date"])[:10], "%Y-%m-%d").date()
if d.year == current_year and d.month == current_month:
month_runs.append(r)
except Exception:
continue
filtered_runs = month_runs

elif filter_opt == "5k10k":
temp = []
for r in runs:
dist = r["distance_km"]
if (4.5 <= dist <= 5.5) or (9.0 <= dist <= 11.0):
temp.append(r)
filtered_runs = temp

conn.close()

# Pagination
total_count = len(filtered_runs)
paginated = filtered_runs[offset : offset + limit]

return jsonify({
"status": "success",
"runs": [dict(r) for r in paginated],
"total": total_count,
"offset": offset,
"limit": limit
})

@app.route("/api/monthly-progress", methods=["GET"])
def api_monthly_progress():
if not require_login():
Expand Down
3 changes: 2 additions & 1 deletion templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3636,7 +3636,7 @@ <h4 class="mb-0 fw-bold" style="text-transform: uppercase; letter-spacing: 0.5px
}
}
</style>
<div id="monthly-progress-container" class="glass p-3 p-md-4 mb-4 d-flex flex-column justify-content-between mx-auto" style="border: 1px solid rgba(255, 255, 255, 0.05); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); position: relative; overflow: hidden; max-width: 480px; width: 100%; border-radius: 20px; aspect-ratio: 1 / 1;">
<div id="monthly-progress-container" class="glass p-3 p-md-4 mb-4 d-flex flex-column justify-content-between mx-auto" style="border: 1px solid rgba(255, 255, 255, 0.05); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); position: relative; overflow: hidden; max-width: 480px; width: 100%; border-radius: 20px; ">
<div style="position: absolute; top: -50%; left: -50%; width: 200%; height: 200%; background: radial-gradient(circle at top right, rgba(22, 131, 247, 0.04), transparent 60%); pointer-events: none;"></div>
<div style="position: relative; z-index: 1;" class="d-flex flex-column h-100">

Expand All @@ -3656,6 +3656,7 @@ <h4 class="mb-0 fw-bold" style="text-transform: uppercase; letter-spacing: 0.5px
</div>
</div>

</div>
</div>

<!-- CARDS VIEW -->
Expand Down
133 changes: 133 additions & 0 deletions tests/test_api_runs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import pytest
from datetime import datetime, timedelta
from models.user import User

def test_api_runs_auth_required(client):
"""1. /api/runs requires authentication."""
resp = client.get('/api/runs')
assert resp.status_code == 401
assert resp.json["error"] == "Unauthorized"

def test_api_runs_data_isolation(client, app):
"""2. User A cannot receive User B's runs."""
with app.app_context():
from app import get_db
conn = get_db()
hashed_pin = User.hash_pin('1234')
conn.execute("INSERT INTO users (username, pin) VALUES ('userA', ?), ('userB', ?)", (hashed_pin, hashed_pin))
user_a = conn.execute("SELECT id FROM users WHERE username='userA'").fetchone()['id']
user_b = conn.execute("SELECT id FROM users WHERE username='userB'").fetchone()['id']
conn.execute("INSERT INTO runs (user_id, distance_km, time_min, pace, calories, date) VALUES (?, 5, 30, 6, 300, '2026-09-01')", (user_a,))
conn.execute("INSERT INTO runs (user_id, distance_km, time_min, pace, calories, date) VALUES (?, 10, 60, 6, 600, '2026-09-02')", (user_b,))
conn.commit()

client.post('/login', data={'username': 'userA', 'pin': '1234'})
resp = client.get('/api/runs')
assert resp.status_code == 200
runs = resp.json["runs"]
assert len(runs) == 1
assert runs[0]["distance_km"] == 5

def test_api_runs_pagination(client, app):
"""3, 4, 5, 6. Pagination offset and limits."""
with app.app_context():
from app import get_db
conn = get_db()
hashed_pin = User.hash_pin('1234')
conn.execute("INSERT INTO users (username, pin) VALUES ('userC', ?)", (hashed_pin,))
user_c = conn.execute("SELECT id FROM users WHERE username='userC'").fetchone()['id']
for i in range(35):
date_str = (datetime(2026, 9, 1) + timedelta(days=i)).strftime('%Y-%m-%d')
conn.execute("INSERT INTO runs (user_id, distance_km, time_min, pace, calories, date) VALUES (?, ?, 30, 6, 300, ?)", (user_c, float(i), date_str))
conn.commit()

client.post('/login', data={'username': 'userC', 'pin': '1234'})

resp1 = client.get('/api/runs?offset=0&limit=15&sort=date_asc')
assert resp1.status_code == 200
batch1 = resp1.json["runs"]
assert len(batch1) == 15
assert batch1[0]["distance_km"] == 0.0
assert batch1[-1]["distance_km"] == 14.0

resp2 = client.get('/api/runs?offset=15&limit=15&sort=date_asc')
batch2 = resp2.json["runs"]
assert len(batch2) == 15
assert batch2[0]["distance_km"] == 15.0

ids1 = {r["id"] for r in batch1}
ids2 = {r["id"] for r in batch2}
assert ids1.isdisjoint(ids2)

resp3 = client.get('/api/runs?offset=30&limit=15&sort=date_asc')
batch3 = resp3.json["runs"]
assert len(batch3) == 5
assert batch3[0]["distance_km"] == 30.0

def test_api_runs_invalid_params(client, app):
"""7. Invalid offset/limit are handled safely."""
with app.app_context():
from app import get_db
conn = get_db()
hashed_pin = User.hash_pin('1234')
conn.execute("INSERT INTO users (username, pin) VALUES ('userD', ?)", (hashed_pin,))
conn.commit()

client.post('/login', data={'username': 'userD', 'pin': '1234'})

resp = client.get('/api/runs?offset=abc')
assert resp.status_code == 400

resp = client.get('/api/runs?offset=-5')
assert resp.status_code == 400

def test_api_runs_filter_sort(client, app):
"""8. Existing sort/filter behavior matches."""
with app.app_context():
from app import get_db
conn = get_db()
hashed_pin = User.hash_pin('1234')
conn.execute("INSERT INTO users (username, pin) VALUES ('userE', ?)", (hashed_pin,))
user_e = conn.execute("SELECT id FROM users WHERE username='userE'").fetchone()['id']

conn.execute("INSERT INTO runs (user_id, distance_km, time_min, pace, calories, date) VALUES (?, 10, 60, 6, 600, '2026-09-01')", (user_e,))
conn.execute("INSERT INTO runs (user_id, distance_km, time_min, pace, calories, date) VALUES (?, 5, 30, 6, 300, '2026-09-02')", (user_e,))
conn.commit()

client.post('/login', data={'username': 'userE', 'pin': '1234'})

resp = client.get('/api/runs?sort=distance_desc')
runs = resp.json["runs"]
assert runs[0]["distance_km"] == 10
assert runs[1]["distance_km"] == 5

resp = client.get('/api/runs?filter=5k10k')
assert len(resp.json["runs"]) == 2

def test_api_runs_can_retrieve_all(client, app):
"""9. A user with >30 runs can eventually retrieve every run."""
with app.app_context():
from app import get_db
conn = get_db()
hashed_pin = User.hash_pin('1234')
conn.execute("INSERT INTO users (username, pin) VALUES ('userF', ?)", (hashed_pin,))
user_f = conn.execute("SELECT id FROM users WHERE username='userF'").fetchone()['id']
for i in range(100):
conn.execute("INSERT INTO runs (user_id, distance_km, time_min, pace, calories, date) VALUES (?, 5, 30, 6, 300, '2026-09-01')", (user_f,))
conn.commit()

client.post('/login', data={'username': 'userF', 'pin': '1234'})

offset = 0
limit = 15
total_loaded = 0

while True:
resp = client.get(f'/api/runs?offset={offset}&limit={limit}')
batch = resp.json["runs"]
if not batch:
break
total_loaded += len(batch)
offset += limit

assert total_loaded == 100
Loading