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} />,
);