diff --git a/app.py b/app.py index 66cece5..198cf83 100644 --- a/app.py +++ b/app.py @@ -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(): @@ -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": ,\n' ' "duration_seconds": ,\n' ' "pace_per_km": ,\n' - ' "date": ,\n' + ' "date": ,\n' + ' "date_relative": ,\n' ' "calories": ,\n' ' "average_heart_rate": ,\n' ' "elevation_gain_m": ,\n' @@ -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"), @@ -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 diff --git a/static/img/example-run-screenshot.webp b/static/img/example-run-screenshot.webp new file mode 100644 index 0000000..05893f3 Binary files /dev/null and b/static/img/example-run-screenshot.webp differ diff --git a/templates/index.html b/templates/index.html index 090965f..c15dbc8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -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; @@ -3661,7 +3713,28 @@
Select Strava CSV File

Upload a screenshot from Strava, Nike Run Club, Apple Fitness, Garmin, Adidas Running, or any running app. Our AI will read the run data. You can review and edit all fields before importing. +

+
+
+ + Example Strava run screenshot + +
+
What a good screenshot looks like
+

Use the run's summary screen, with the stats in view:

+
    +
  • Distance and moving time (required)
  • +
  • Date or "Today at …" line
  • +
  • Avg pace, calories, elevation, heart rate if shown
  • +
+

Works with Strava, Nike Run Club, Apple Fitness, Garmin, Adidas Running and similar apps. Avoid cropped, blurry or share-card images.

+
+
+
📸
Select Screenshot
@@ -6469,6 +6542,12 @@
Sort
} } + // 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]; @@ -6487,6 +6566,7 @@
Sort
const formData = new FormData(); formData.append('file', file); + formData.append('client_date', localDateISO()); try { const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); @@ -6638,6 +6718,7 @@
Sort
const formData = new FormData(); formData.append('file', file); + formData.append('client_date', localDateISO()); try { const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); diff --git a/tests/test_screenshot_import.py b/tests/test_screenshot_import.py index 96f538a..aff3b02 100644 --- a/tests/test_screenshot_import.py +++ b/tests/test_screenshot_import.py @@ -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 # ---------------------------------------------------------------------------