diff --git a/app/history.py b/app/history.py index 697d968..1bf72b9 100644 --- a/app/history.py +++ b/app/history.py @@ -97,7 +97,8 @@ def latest_mapped_run(filters): ) -def records_for(owner_id, run_ids): +def records_for(owner_id, run_ids, shared_only=False): + visibility_filters = [Activity.visibility == "friends"] if shared_only else [] history = db.session.execute( db.select( Activity.id, @@ -108,6 +109,7 @@ def records_for(owner_id, run_ids): Activity.best_efforts, ) .where( + *visibility_filters, Activity.owner_id == owner_id, Activity.sport == "running", Activity.efforts_version == EFFORTS_VERSION, diff --git a/app/routes.py b/app/routes.py index 0f9f7b9..a553f02 100644 --- a/app/routes.py +++ b/app/routes.py @@ -260,6 +260,8 @@ def profile(username): user = db.first_or_404(db.select(User).filter_by(username=username)) if user.id != current_user.id and not are_friends(current_user.id, user.id): abort(404) + if user.id == current_user.id: + return redirect(url_for("web.dashboard")) filters = [Activity.owner_id == user.id, Activity.visibility == "friends"] stats = overview(filters, user.timezone) pagination = activity_page(filters, total=stats["count"]) @@ -271,6 +273,8 @@ def profile(username): profile=user, personal=False, mapped_run=latest_mapped_run(filters), + record_book=records_for(user.id, {row.id for row in pagination.items}, shared_only=True), + pace_text=pace_text, ) diff --git a/app/static/style.css b/app/static/style.css index 5f7fe1c..af294e6 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -42,3 +42,9 @@ a.record-badge:focus-visible { outline: 2px solid #f3cb78; outline-offset: 3px; .history-pagination { display:flex; align-items:center; justify-content:space-between; gap:16px; padding-top:20px; border-top:1px solid var(--line); } .history-pagination > div { display:flex; align-items:center; gap:12px; } @media(max-width:680px) { .history-pagination { flex-direction:column; align-items:flex-start; } .history-pagination > div { flex-wrap:wrap; } } + +/* Compact application navigation */ +.app-shell main{margin-left:0}.app-header{max-width:1506px;margin:auto;padding:0 42px;height:84px;display:flex;align-items:center;gap:28px;border-bottom:1px solid var(--line)}.header-nav{display:flex;align-items:center;gap:8px;margin-left:auto}.header-nav a{padding:9px 13px;border-radius:5px;color:var(--muted);font-size:13px}.header-nav a:hover,.header-nav a.active{background:#292f32;color:var(--ink)}.header-actions{display:flex;align-items:center;gap:14px}.account-dropdown{position:relative}.account-dropdown summary{cursor:pointer;list-style:none}.account-dropdown summary::-webkit-details-marker{display:none}.account-dropdown-menu{position:absolute;right:0;top:46px;width:240px;padding:12px;background:#242b2e;border:1px solid #485154;border-radius:7px;box-shadow:0 12px 35px #0008;z-index:1100}.account-dropdown-menu>strong{display:block;padding:8px 10px;font-size:12px;color:var(--muted);border-bottom:1px solid #3c4447;margin-bottom:5px}.account-dropdown-menu>a,.account-dropdown-menu button{display:block;width:100%;text-align:left;padding:10px;border-radius:4px;color:var(--ink);font-size:12px}.account-dropdown-menu>a:hover,.account-dropdown-menu button:hover{background:#343d40}.account-dropdown summary:focus-visible{outline:2px solid #ff956a;outline-offset:4px} +@media(max-width:680px){.app-header{height:auto;min-height:72px;padding:16px 18px;gap:12px;flex-wrap:wrap}.app-header .brand{font-size:25px;gap:8px}.app-header .brand-mark{width:28px;height:28px;font-size:25px}.header-nav{gap:0}.header-nav a{padding:8px;font-size:12px}.header-actions{gap:8px}.header-import{order:1;padding:8px 10px;font-size:11px}.header-import .icon{display:none}.app-header .brand-dot{margin-left:-6px}.account-dropdown-menu{width:220px}.app-shell .page{padding-top:25px}.page-heading h1{font-size:30px}} + +@media(max-width:680px){.header-import{display:none}} diff --git a/app/templates/base.html b/app/templates/base.html index 8697d1d..ec4bffd 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -10,28 +10,11 @@ {% if current_user.is_authenticated %} - +
+ sstride. + +
{% if request.endpoint != 'web.profile' %}{{ icon('plus') }} Import run{% endif %}
+
{% else %}
sstride. @@ -42,9 +25,6 @@
{% endif %}
- {% if current_user.is_authenticated %} -
YOUR SPACE / {% block breadcrumb %}RUNNING{% endblock %} Personal running log
- {% endif %}
{% for category, message in get_flashed_messages(with_categories=true) %}
{{ message }}
{% endfor %} {% block content %}{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 20898cc..787ea68 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -3,7 +3,8 @@ {% block breadcrumb %}{{ 'OVERVIEW' if personal else 'SHARED PROFILE' }}{% endblock %} {% block head %}{% endblock %} {% block content %} -
{{ 'TRAINING LOG' if personal else 'SHARED WITH FRIENDS' }}

{{ 'Overview' if personal else profile.display_name }}

{{ 'Your distance, effort, and recent sessions.' if personal else 'Shared sessions and training totals.' }}

{% if personal %}{{ icon('plus') }} Import activity{% else %}Friends only{% endif %}
+{% if not personal %}← Friends{% endif %} +

{{ 'Your running' if personal else profile.display_name ~ '’s running' }}

{{ 'Distance, effort, and your recent sessions.' if personal else 'Activities shared with friends.' }}

{% if not personal %}Friends · Shared runs{% endif %}
LATEST RUN / ROUTE REPLAY

{{ mapped_run.title if mapped_run else 'Your next run starts here.' }}

{% if mapped_run %}

{{ mapped_run.started_at|localdate(mapped_run.owner.timezone) }} · {{ '%.2f'|format(mapped_run.distance_km) }} km

View run {{ icon('arrow') }}{% else %}

Import a run with GPS to see your route in motion.

{% endif %} @@ -20,7 +21,7 @@
WEEKLY DISTANCE

Training volume

8 weeks to latest activity
TOTAL ASCENT
{{ '{:,.0f}'.format(stats.elevation) }}m

Elevation gain

Across {{ stats.count }} recorded activities

-{% if personal and record_book.records %} +{% if record_book.records %} {% endif %}
SESSION HISTORY

{{ 'Your activities' if personal else 'Shared activities' }}

{{ stats.count }} activities
{% include '_activities.html' %}
diff --git a/design/navigation-mockups.html b/design/navigation-mockups.html new file mode 100644 index 0000000..6b78e49 --- /dev/null +++ b/design/navigation-mockups.html @@ -0,0 +1,23 @@ + +Stride · Navigation concepts + +

Stride / Navigation concepts

Interactive mockups · sample data · app unchanged

+
A · Top navigationDashboard and Friends stay visible. Imports and account settings move into the top bar. Familiar navigation, more room for your runs.
+
+

Your running

Distance, effort, and your recent sessions.

+
LATEST RUN

Morning riverside

2 Oct 2026 · 8.20 km

View run →
+
DISTANCE LOGGED
186.4km

Across all your activities

ACTIVITIES
24

Recorded sessions

TIME ON YOUR FEET
18:42:10

Total recorded timer time

AVERAGE PACE
6:01/km

Weighted by distance

+

Training volume

Last 8 weeks
10 Aug17 Aug24 Aug31 Aug7 Sep14 Sep21 Sep28 Sep
TOTAL ASCENT
1,420m
+
★ 1 km 4:12/km★ 2 km 4:26/km★ 5 km 4:48/km★ 10 km 5:03/km
+

Your activities

24 activities
ACTIVITYDISTANCETIMEAVG. PACEVISIBILITY
Morning riverside2 Oct 2026 · Running8.20 km46:445:42 /kmFriends
Recovery jog30 Sep 2026 · Running3.40 km23:146:50 /kmPrivate
Sunday long run27 Sep 2026 · Running14.60 km1:25:105:50 /kmFriends
+

Your circle

Open a friend’s dashboard to catch up on their running.

Friend requests and connection management live here.

+
+ \ No newline at end of file diff --git a/tests/test_app.py b/tests/test_app.py index afc0172..0388e0d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -805,3 +805,62 @@ def test_refresh_preserves_sharing_and_converts_running_units(app): assert result.samples[0]["vertical_oscillation"] == 8.5 assert result.samples[0]["ground_contact"] == 250 assert result.samples[0]["power"] == 300 + + +def test_friend_dashboard_records_and_totals_exclude_private_runs(app): + from flask import template_rendered + + with app.app_context(): + private = db.session.get(Activity, 1) + private.samples = [{"distance": 0, "time": 0}, {"distance": 5000, "time": 1000}] + persist_efforts(private) + shared = Activity( + owner_id=1, + source_key="shared-record", + title="Shared slower run", + sport="running", + visibility="friends", + has_route=True, + started_at=datetime(2026, 10, 1, tzinfo=UTC), + distance_m=5000, + duration_s=1500, + elevation_m=20, + samples=[{"distance": 0, "time": 0}, {"distance": 5000, "time": 1500}], + ) + persist_efforts(shared) + shared.has_route = True + db.session.add(shared) + db.session.add(Friendship(low_id=1, high_id=2, requested_by=1, status="accepted")) + db.session.commit() + contexts = [] + + def capture(_app, template, context, **_extra): + contexts.append(context) + + template_rendered.connect(capture, app) + client = app.test_client() + login(client, "other") + try: + response = client.get("/u/runner") + assert response.status_code == 200 + context = contexts[-1] + assert context["stats"]["count"] == 1 + assert context["stats"]["distance"] == 5 + assert context["stats"]["duration"] == 1500 + assert context["mapped_run"].title == "Shared slower run" + assert all( + record["activity"].title == "Shared slower run" + for record in context["record_book"]["records"] + ) + assert context["record_book"]["records"][-1]["seconds"] == 1500 + assert b"A quiet morning run" not in response.data + assert b"header-import" not in response.data + assert b"Shared profile" not in response.data + assert b'