Workouts
index.jsx 228 lines
Files · 8
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228import "./style.css";
import React from "react";
import { Button } from "../../shared/ui.jsx";
import { createRoot } from "react-dom/client";
import { RecordApp, RecordActions } from "../../shared/records.jsx";
import { displayDate, localISO, number } from "../../shared/format.js";
import { store } from "./store.js";
function sportIcon(sport) {
return /ride/i.test(sport)
? "↗"
: /swim/i.test(sport)
? "≈"
: /walk|hike/i.test(sport)
? "↟"
: "↗";
}
const config = {
slug: "workouts",
singular: "activity",
plural: "activities",
navigationLabel: "Training log",
subtitle: "Activities, distances, and training time.",
listLabel: "Activities",
searchPlaceholder: "Search activities or notes…",
emptyTitle: "Your training log is empty",
emptyDescription:
"Record an activity, or add a few sample runs, rides, and walks.",
title: (row) => row.name,
search: (row) => `${row.name} ${row.sport_type} ${row.description || ""}`,
sort: (a, b) => b.start_date_local.localeCompare(a.start_date_local),
filters: (rows) => [
{ id: "all", label: "All activities", count: rows.length },
...["Run", "Ride", "Walk", "Other"].map((id) => ({
id,
label: id === "Other" ? "Other sports" : `${id}s`,
count: rows.filter((row) =>
id === "Other"
? !["Run", "Ride", "Walk"].includes(row.sport_type)
: row.sport_type === id,
).length,
})),
],
matches: (row, filter) =>
filter === "Other"
? !["Run", "Ride", "Walk"].includes(row.sport_type)
: row.sport_type === filter,
stats: (rows) => [
{
label: "Total distance",
value: `${number(rows.reduce((sum, row) => sum + row.distance, 0) / 1000)} km`,
},
{
label: "Elapsed time",
value: `${number(rows.reduce((sum, row) => sum + row.elapsed_time, 0) / 60)} min`,
},
{
label: "Elevation gain",
value: `${number(rows.reduce((sum, row) => sum + (row.total_elevation_gain ?? 0), 0))} m`,
},
],
fields: [
{ key: "name", label: "Activity name", required: true, wide: true },
{
key: "sport_type",
label: "Sport type",
required: true,
default: () => "Run",
placeholder: "Run, Ride, Swim…",
help: "Any Strava sport type is supported.",
},
{
key: "start_date_local",
label: "Local start",
required: true,
default: localISO,
placeholder: "2026-10-15T07:30:00",
help: "Local wall-clock ISO time; no timezone conversion.",
},
{
key: "distance",
label: "Distance (km)",
numeric: true,
scale: 1000,
default: () => 0,
required: true,
},
{
key: "elapsed_time",
label: "Elapsed time (min)",
numeric: true,
scale: 60,
required: true,
help: "Includes stops.",
},
{
key: "moving_time",
label: "Moving time (min)",
numeric: true,
scale: 60,
help: "Leave blank if unknown.",
},
{ key: "total_elevation_gain", label: "Elevation gain (m)", numeric: true },
{ key: "description", label: "Notes", multiline: true, wide: true },
{
key: "commute",
label: "Commute",
options: [
{ value: 0, label: "No" },
{ value: 1, label: "Yes" },
],
default: () => 0,
},
{
key: "start_date",
label: "UTC start",
advanced: true,
placeholder: "2026-10-15T06:30:00Z",
help: "Optional; kept separate from local time.",
},
{
key: "timezone",
label: "Timezone",
advanced: true,
placeholder: "(GMT+01:00) Europe/London",
},
],
renderCard: (row, actions) => (
<article className="ui-panel ui-record-card ra-activity">
<div className="ra-sport-icon" aria-hidden="true">
{sportIcon(row.sport_type)}
</div>
<div className="ra-activity-body">
<h3 className="ra-card-title">{row.name}</h3>
<div className="ra-meta">
<span>{row.sport_type}</span>
<span className="ra-dot">
{displayDate(row.start_date_local, true)}
</span>
{row.commute === 1 && <span className="ui-badge">Commute</span>}
</div>
<div className="ra-activity-metrics">
<div>
<strong>
{number(row.distance / 1000)}
<small>km</small>
</strong>
<span>Distance</span>
</div>
<div>
<strong>
{number(row.elapsed_time / 60)}
<small>min</small>
</strong>
<span>Elapsed</span>
</div>
{row.moving_time !== null && (
<div>
<strong>
{number(row.moving_time / 60)}
<small>min</small>
</strong>
<span>Moving</span>
</div>
)}
{row.total_elevation_gain !== null && (
<div>
<strong>
{number(row.total_elevation_gain)}
<small>m</small>
</strong>
<span>Elevation</span>
</div>
)}
</div>
{row.description && (
<p className="ra-workout-description">{row.description}</p>
)}
</div>
<RecordActions row={row} actions={actions} title={row.name} />
</article>
),
samples: [
{
name: "Morning loop by the river",
sport_type: "Run",
start_date_local: "2026-10-15T07:15:00Z",
start_date: "2026-10-15T06:15:00Z",
timezone: "(GMT+01:00) Europe/London",
distance: 5240,
elapsed_time: 1920,
moving_time: 1850,
total_elevation_gain: 28,
description: "Easy pace. Cool morning, quiet paths.",
},
{
name: "Ride to the office",
sport_type: "Ride",
start_date_local: "2026-10-14T08:20:00",
distance: 8700,
elapsed_time: 1980,
moving_time: 1620,
total_elevation_gain: 64,
commute: 1,
},
{
name: "Lunch break walk",
sport_type: "Walk",
start_date_local: "2026-10-13T12:30:00",
distance: 2800,
elapsed_time: 2040,
moving_time: 1980,
total_elevation_gain: 12,
},
{
name: "Weekend hills",
sport_type: "Run",
start_date_local: "2026-10-11T09:00:00",
distance: 10200,
elapsed_time: 4020,
moving_time: 3760,
total_elevation_gain: 186,
description: "Longer route through the woods.",
},
],
};
createRoot(document.getElementById("root")).render(
<RecordApp store={store} config={config} />,
);