Replace sidebar with compact header and shared friend dashboards

This commit is contained in:
Peter Stockings
2026-10-02 15:05:02 +10:00
parent ca2926da0a
commit 794e74166a
7 changed files with 103 additions and 28 deletions
+3 -1
View File
@@ -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,
+4
View File
@@ -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,
)
+6
View File
@@ -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}}
+5 -25
View File
@@ -10,28 +10,11 @@
</head>
<body class="{{ 'app-shell' if current_user.is_authenticated else 'public-shell' }}">
{% if current_user.is_authenticated %}
<aside class="sidebar">
<a class="brand" href="{{ url_for('web.index') }}"><span class="brand-mark">s</span>stride<span class="brand-dot">.</span></a>
<div class="sidebar-label">TRAINING LOG</div>
<nav aria-label="Main navigation">
{% if current_user.is_authenticated %}
<a class="nav-item {{ 'active' if request.endpoint == 'web.dashboard' }}" href="{{ url_for('web.dashboard') }}">{{ icon('overview') }} Overview</a>
<a class="nav-item {{ 'active' if request.endpoint == 'web.profile' }}" href="{{ url_for('web.profile', username=current_user.username) }}">{{ icon('profile') }} Shared profile</a>
{% endif %}
{% if current_user.is_authenticated %}<a class="nav-item {{ 'active' if request.endpoint == 'web.friends' }}" href="{{ url_for('web.friends') }}">{{ icon('friends') }} Friends</a>{% endif %}
{% if current_user.is_authenticated %}
<a class="nav-item {{ 'active' if request.endpoint == 'web.settings' }}" href="{{ url_for('web.settings') }}">{{ icon('settings') }} Settings & imports</a>
{% else %}
<a class="nav-item {{ 'active' if request.endpoint == 'web.login' }}" href="{{ url_for('web.login') }}">{{ icon('arrow') }} Sign in</a>
{% endif %}
</nav>
<div class="sidebar-bottom">
{% if current_user.is_authenticated %}
<div class="account"><span class="avatar">{{ current_user.display_name[:1]|upper }}</span><div><strong>{{ current_user.display_name }}</strong><small>Runner</small></div></div>
<form method="post" action="{{ url_for('web.logout') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button logout">Sign out {{ icon('arrow') }}</button></form>
{% else %}<span class="muted">Made for the long run.</span>{% endif %}
</div>
</aside>
<header class="app-header">
<a class="brand" href="{{ url_for('web.dashboard') }}"><span class="brand-mark">s</span>stride<span class="brand-dot">.</span></a>
<nav class="header-nav" aria-label="Main navigation"><a class="{{ 'active' if request.endpoint == 'web.dashboard' }}" href="{{ url_for('web.dashboard') }}">Dashboard</a><a class="{{ 'active' if request.endpoint in ('web.friends', 'web.profile') }}" href="{{ url_for('web.friends') }}">Friends</a></nav>
<div class="header-actions">{% if request.endpoint != 'web.profile' %}<a class="button primary header-import" href="{{ url_for('web.settings') }}">{{ icon('plus') }} Import run</a>{% endif %}<details class="account-dropdown"><summary class="avatar" aria-label="Account menu" title="Account menu">{{ current_user.display_name[:1]|upper }}</summary><div class="account-dropdown-menu"><strong>{{ current_user.display_name }}</strong><a href="{{ url_for('web.settings') }}">Settings & Garmin sync</a><a href="{{ url_for('web.settings') }}">Import runs</a><form method="post" action="{{ url_for('web.logout') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button">Sign out</button></form></div></details></div>
</header>
{% else %}
<header class="landing-header">
<a class="brand" href="{{ url_for('web.index') }}" aria-label="Stride home"><span class="brand-mark">s</span>stride<span class="brand-dot">.</span></a>
@@ -42,9 +25,6 @@
</header>
{% endif %}
<main>
{% if current_user.is_authenticated %}
<header class="topbar"><span class="breadcrumb">YOUR SPACE <span>/</span> {% block breadcrumb %}RUNNING{% endblock %}</span><span class="local-badge"><i></i> Personal running log</span></header>
{% endif %}
<div class="page">
{% for category, message in get_flashed_messages(with_categories=true) %}<div role="status" class="flash {{ category }}">{{ message }}</div>{% endfor %}
{% block content %}{% endblock %}
+3 -2
View File
@@ -3,7 +3,8 @@
{% block breadcrumb %}{{ 'OVERVIEW' if personal else 'SHARED PROFILE' }}{% endblock %}
{% block head %}<link rel="stylesheet" href="{{ url_for('static',filename='vendor/leaflet.css') }}">{% endblock %}
{% block content %}
<div class="page-heading"><div><span class="eyebrow">{{ 'TRAINING LOG' if personal else 'SHARED WITH FRIENDS' }}</span><h1>{{ 'Overview' if personal else profile.display_name }}</h1><p>{{ 'Your distance, effort, and recent sessions.' if personal else 'Shared sessions and training totals.' }}</p></div>{% if personal %}<a class="button primary" href="{{ url_for('web.settings') }}">{{ icon('plus') }} Import activity</a>{% else %}<span class="pill public">Friends only</span>{% endif %}</div>
{% if not personal %}<a class="back-link" href="{{ url_for('web.friends') }}">← Friends</a>{% endif %}
<div class="page-heading"><div><h1>{{ 'Your running' if personal else profile.display_name ~ '’s running' }}</h1><p>{{ 'Distance, effort, and your recent sessions.' if personal else 'Activities shared with friends.' }}</p></div>{% if not personal %}<span class="pill friends">Friends · Shared runs</span>{% endif %}</div>
<section class="dashboard-route panel" aria-label="Latest running route">
<div class="dashboard-route-info"><span class="eyebrow">LATEST RUN / ROUTE REPLAY</span><h2>{{ mapped_run.title if mapped_run else 'Your next run starts here.' }}</h2>
{% if mapped_run %}<p>{{ mapped_run.started_at|localdate(mapped_run.owner.timezone) }} · {{ '%.2f'|format(mapped_run.distance_km) }} km</p><a class="back-link" href="{{ url_for('web.activity',activity_id=mapped_run.id) }}">View run {{ icon('arrow') }}</a>{% else %}<p>Import a run with GPS to see your route in motion.</p>{% endif %}
@@ -20,7 +21,7 @@
<section class="panel volume-panel"><div class="section-head"><div><span class="eyebrow">WEEKLY DISTANCE</span><h2>Training volume</h2></div><span class="subtle-label">8 weeks to latest activity</span></div><div class="chart-wrap"><canvas id="volume-chart" aria-label="Weekly distance in kilometres" role="img"></canvas></div><script id="volume-data" type="application/json">{{ {'labels': stats.weeks, 'values': stats.weekly_km}|tojson }}</script></section>
<section class="milestone-card"><span class="eyebrow">TOTAL ASCENT</span><div class="milestone-number">{{ '{:,.0f}'.format(stats.elevation) }}<small>m</small></div><h3>Elevation gain</h3><div class="elevation-profile" aria-hidden="true"><svg viewBox="0 0 300 70"><path d="m0 65 30-15 15 8 35-35 20 15 35-32 25 32 20-12 25 28 20-12 30 23 25-10"/></svg></div><p>Across {{ stats.count }} recorded activities</p></section>
</div>
{% if personal and record_book.records %}
{% if record_book.records %}
<div class="record-badges run-record-badges" aria-label="Personal records">{% for record in record_book.records %}<a class="record-badge is-pr" href="{{ url_for('web.activity', activity_id=record.activity.id) }}" title="{{ record.activity.started_at|localdate(profile.timezone) }} · {{ record.activity.title }}"><span class="badge-award" aria-hidden="true">★</span><span>{{ record.label }}</span><strong>{{ pace_text(record.seconds * 1000 / record.distance) }}<small>/km</small></strong></a>{% endfor %}</div>
{% endif %}
<section class="panel activities-panel" id="history"><div class="section-head"><div><span class="eyebrow">SESSION HISTORY</span><h2>{{ 'Your activities' if personal else 'Shared activities' }}</h2></div><span class="subtle-label">{{ stats.count }} activities</span></div>{% include '_activities.html' %}</section>
File diff suppressed because one or more lines are too long
+59
View File
@@ -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'<aside class="sidebar"' not in response.data
assert b"Friends \xc2\xb7 Shared runs" in response.data
assert client.post("/activities/2/edit", data={"title": "changed"}).status_code == 404
finally:
template_rendered.disconnect(capture, app)
owner = app.test_client()
login(owner)
assert owner.get("/u/runner").location.endswith("/dashboard")