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
56 changes: 53 additions & 3 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3392,6 +3392,49 @@ def confirm_import():

# ---------- SCREENSHOT IMPORT ----------

_WEEKDAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]


def _screenshot_reference_date(client_date_str):
"""
The user's local "today", sent by the browser. Falls back to the server's date
when missing/invalid or more than a day away from it (timezones differ by < 1 day).
"""
server_today = date.today()
try:
client_today = datetime.strptime(str(client_date_str or "").strip(), "%Y-%m-%d").date()
except ValueError:
return server_today
if abs((client_today - server_today).days) > 1:
return server_today
return client_today


def _resolve_screenshot_date(raw_date, relative, today):
"""
Turn what the model read off the screenshot into a YYYY-MM-DD string.
Relative labels ("Today", "Yesterday", "Monday") are resolved against `today`;
an absolute date is kept if valid and not in the future; otherwise default to today.
"""
rel = str(relative or "").strip().lower()
if rel == "today":
return today.isoformat()
if rel == "yesterday":
return (today - timedelta(days=1)).isoformat()
if rel in _WEEKDAYS:
# Apps show a weekday name for runs earlier in the past week, never for today
days_back = (today.weekday() - _WEEKDAYS.index(rel)) % 7 or 7
return (today - timedelta(days=days_back)).isoformat()

try:
parsed = datetime.strptime(str(raw_date or "").strip(), "%Y-%m-%d").date()
if parsed <= today:
return parsed.isoformat()
except ValueError:
pass
return today.isoformat()


@app.route("/api/parse-screenshot", methods=["POST"])
@limiter.limit("20 per hour")
def parse_screenshot():
Expand Down Expand Up @@ -3482,14 +3525,20 @@ def parse_screenshot():
f"({_orig_size / 1024:.1f} KB → {_new_size / 1024:.1f} KB)"
)

today = _screenshot_reference_date(request.form.get("client_date"))

PROMPT = (
"You are a running data extractor. The user has uploaded a screenshot from a running app.\n"
f"The user's date today is {today.isoformat()} ({today.strftime('%A')}).\n"
"Extract ONLY the following fields as strict JSON, with no extra text or markdown:\n"
"{\n"
' "distance_km": <float or null. If distance is in miles, multiply by 1.609 to get km>,\n'
' "duration_seconds": <int or null. IMPORTANT: convert time like "45:30" to total seconds (e.g., 2730)>,\n'
' "pace_per_km": <string like "5:30" or null. If pace is per mile, convert to pace per km>,\n'
' "date": <ISO date string "YYYY-MM-DD" if visible, else null>,\n'
' "date": <ISO date string "YYYY-MM-DD" if an actual date is visible, else null. '
'If the year is not shown, use the most recent such date that is not after today>,\n'
' "date_relative": <if the run date is shown as a relative word instead of a date, one of '
'"today", "yesterday", or a lowercase weekday name like "monday"; else null>,\n'
' "calories": <int or null>,\n'
' "average_heart_rate": <int or null>,\n'
' "elevation_gain_m": <float or null. If in feet, multiply by 0.3048>,\n'
Expand Down Expand Up @@ -3586,7 +3635,7 @@ def parse_screenshot():
"time_min": time_min,
"duration_seconds": duration_seconds,
"pace_per_km": parsed.get("pace_per_km"),
"date": parsed.get("date"),
"date": _resolve_screenshot_date(parsed.get("date"), parsed.get("date_relative"), today),
"calories": parsed.get("calories"),
"average_heart_rate": parsed.get("average_heart_rate"),
"elevation_gain_m": parsed.get("elevation_gain_m"),
Expand Down Expand Up @@ -3627,7 +3676,8 @@ def confirm_screenshot_import():
date_str = datetime.now().strftime("%Y-%m-%d")
try:
run_date = datetime.strptime(date_str, "%Y-%m-%d").date()
if run_date > date.today():
# +1 day: the user's local "today" can be ahead of the server's date
if run_date > date.today() + timedelta(days=1):
return jsonify({"error": "Run date cannot be in the future"}), 400
except ValueError:
return jsonify({"error": "Invalid date format — expected YYYY-MM-DD"}), 400
Expand Down
Binary file added static/img/example-run-screenshot.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
81 changes: 81 additions & 0 deletions templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,58 @@
box-shadow: 0 0 24px rgba(255, 110, 58, 0.15);
}

.ss-example-info-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-left: 4px;
padding: 0;
border-radius: 50%;
border: 1px solid #ff6e3a;
background: transparent;
color: #ff6e3a;
font-size: 0.65rem;
vertical-align: middle;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
}

.ss-example-info-btn:hover,
.ss-example-info-btn[aria-expanded="true"] {
background: #ff6e3a;
color: #fff;
}

.ss-example-panel {
display: flex;
gap: 1rem;
align-items: flex-start;
padding: 0.85rem;
border-radius: 12px;
border: 1px solid var(--border-color);
background: var(--border-subtle);
}

.ss-example-img {
width: 130px;
height: auto;
border-radius: 10px;
border: 1px solid var(--border-color);
flex-shrink: 0;
}

@media (max-width: 480px) {
.ss-example-panel {
flex-direction: column;
align-items: center;
}
.ss-example-img {
width: 160px;
}
}

.upload-icon {
font-size: 2.8rem;
margin-bottom: 1rem;
Expand Down Expand Up @@ -3661,7 +3713,28 @@ <h6>Select Strava CSV File</h6>
<p class="text-muted small mb-3">
Upload a screenshot from <strong>Strava, Nike Run Club, Apple Fitness, Garmin, Adidas Running</strong>, or any running app.
Our AI will read the run data. You can review and edit all fields before importing.
<button type="button" class="ss-example-info-btn" data-bs-toggle="collapse" data-bs-target="#ssExamplePanel"
aria-expanded="false" aria-controls="ssExamplePanel" title="See an example screenshot">
<i class="fas fa-info"></i>
</button>
</p>
<div class="collapse" id="ssExamplePanel">
<div class="ss-example-panel mb-3">
<a href="{{ url_for('static', filename='img/example-run-screenshot.webp') }}" target="_blank" rel="noopener" title="Open full size">
<img src="{{ url_for('static', filename='img/example-run-screenshot.webp') }}" alt="Example Strava run screenshot" class="ss-example-img" loading="lazy">
</a>
<div class="ss-example-text">
<h6 class="mb-2">What a good screenshot looks like</h6>
<p class="text-muted small mb-2">Use the run's summary screen, with the stats in view:</p>
<ul class="small mb-2 ps-3">
<li><strong>Distance</strong> and <strong>moving time</strong> (required)</li>
<li>Date or "Today at …" line</li>
<li>Avg pace, calories, elevation, heart rate if shown</li>
</ul>
<p class="text-muted small mb-0">Works with Strava, Nike Run Club, Apple Fitness, Garmin, Adidas Running and similar apps. Avoid cropped, blurry or share-card images.</p>
</div>
</div>
</div>
<div class="upload-dropzone" id="screenshotDropZone" onclick="if(event.target.id !== 'screenshotInput') document.getElementById('screenshotInput').click()">
<div class="upload-icon">📸</div>
<h6>Select Screenshot</h6>
Expand Down Expand Up @@ -6469,6 +6542,12 @@ <h6 class="text-muted small mb-2 text-uppercase fw-bold">Sort</h6>
}
}

// Browser-local YYYY-MM-DD, so "Today" on a screenshot resolves to the user's date
function localDateISO() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}

async function uploadAndParseScreenshot() {
const input = document.getElementById('screenshotInput');
const file = input?.files[0];
Expand All @@ -6487,6 +6566,7 @@ <h6 class="text-muted small mb-2 text-uppercase fw-bold">Sort</h6>

const formData = new FormData();
formData.append('file', file);
formData.append('client_date', localDateISO());

try {
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
Expand Down Expand Up @@ -6638,6 +6718,7 @@ <h6 class="text-muted small mb-2 text-uppercase fw-bold">Sort</h6>

const formData = new FormData();
formData.append('file', file);
formData.append('client_date', localDateISO());

try {
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
Expand Down
58 changes: 58 additions & 0 deletions tests/test_screenshot_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,64 @@ def test_api_exception_returns_502(self, auth_client):
assert "AI service" in res["error"] or "manually" in res["error"]


@patch.dict(os.environ, {"GEMINI_API_KEY": "test-key"})
def test_relative_today_resolves_to_client_date(self, auth_client):
"""Screenshot says "Today" → date is filled with the user's local date."""
from datetime import date
today = date.today().isoformat()
rel_json = json.dumps({**json.loads(VALID_JSON), "date": None, "date_relative": "today"})

with patch("google.genai.Client") as MockClientClass:
mock_client = MagicMock()
mock_client.models.generate_content.return_value = _mock_gemini_response(rel_json)
MockClientClass.return_value = mock_client

data = {
"file": (io.BytesIO(_fake_png()), "strava.png", "image/png"),
"client_date": today,
}
resp = auth_client.post(
"/api/parse-screenshot",
data=data,
content_type="multipart/form-data",
)

assert resp.status_code == 200
assert resp.get_json()["data"]["date"] == today


class TestResolveScreenshotDate:
"""Unit tests for the relative-date helpers (no HTTP, no Gemini)."""

def test_relative_labels(self):
from datetime import date
from app import _resolve_screenshot_date
today = date(2026, 10, 4) # a Sunday
assert _resolve_screenshot_date(None, "Today", today) == "2026-10-04"
assert _resolve_screenshot_date(None, "yesterday", today) == "2026-10-03"
assert _resolve_screenshot_date(None, "friday", today) == "2026-10-02"
# Same weekday as today means a week ago, not today
assert _resolve_screenshot_date(None, "sunday", today) == "2026-09-27"

def test_absolute_and_fallbacks(self):
from datetime import date
from app import _resolve_screenshot_date
today = date(2026, 10, 4)
assert _resolve_screenshot_date("2026-08-15", None, today) == "2026-08-15"
assert _resolve_screenshot_date(None, None, today) == "2026-10-04"
assert _resolve_screenshot_date("2026-12-25", None, today) == "2026-10-04"
assert _resolve_screenshot_date("garbage", None, today) == "2026-10-04"

def test_reference_date_rejects_far_off_client_date(self):
from datetime import date, timedelta
from app import _screenshot_reference_date
server_today = date.today()
tomorrow = (server_today + timedelta(days=1)).isoformat()
assert _screenshot_reference_date(tomorrow) == server_today + timedelta(days=1)
assert _screenshot_reference_date("2000-01-01") == server_today
assert _screenshot_reference_date(None) == server_today


# ---------------------------------------------------------------------------
# confirm-screenshot-import tests
# ---------------------------------------------------------------------------
Expand Down
Loading