Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ client/src/
├── components/ui/ # shadcn/ui components
├── hooks/
│ ├── use-form-persistence.ts # Auto-save/restore form draft to localStorage
│ ├── use-settings.ts # Persisted app settings (e.g. TrainerRoad metrics visibility)
│ ├── use-theme.ts # Dark/light theme toggle
│ └── use-toast.ts # Toast notification system
└── test/ # Vitest tests
Expand All @@ -65,7 +66,9 @@ scripts/ # AWS S3 deployment automation
- `shared/schema-static.ts` - Zod schema defining workout data structure
- `client/src/hooks/use-form-persistence.ts` - Debounced form draft auto-save/restore
- `client/src/hooks/use-section-state.ts` - Persisted open/closed state for collapsible form sections
- `client/src/hooks/use-settings.ts` - Persisted app settings (TrainerRoad metrics visibility)
- `client/src/components/ui/collapsible-section.tsx` - Reusable `<details>`-based collapsible section component
- `client/src/components/ui/settings-panel.tsx` - Settings modal (opened via gear icon in the header)

### PWA Setup
- `vite-plugin-pwa` generates the service worker (Workbox) and injects manifest link automatically
Expand Down Expand Up @@ -93,6 +96,13 @@ scripts/ # AWS S3 deployment automation
- Section IDs: `daily-notes`, `core-metrics`, `fueling`, `performance-metrics`, `recovery-metrics`, `reflection`, `rest-day`, `activity`
- Visible sections depend on `entryType` — `expandAllOrCollapseAll` only affects currently visible sections; `daily-notes` is included in all three entry type section lists

### Settings Persistence
- `useSettings({ key, defaults })` manages persisted app-wide settings, opened via the gear icon in the header (`SettingsPanel`)
- localStorage key: `"pedalnotes-settings"`; stored as `{ version: 1, data: Settings }` (same envelope shape as section state)
- On mount: reads stored state, merges defaults for any missing keys, drops unknown keys; version mismatch or malformed data falls back to defaults and overwrites stored value
- `setSetting(key, value)` — writes persist immediately (no debounce); `QuotaExceededError` on write is caught and settings still work in memory
- Current setting: `showTrainerRoadMetrics` (boolean, **default `false` — opt-in**). When off, the `trainerRoadRpe` (TR-RPE, Core Metrics) and `trainerRoadLgt` (TR-LGT, Recovery Metrics) fields are hidden from the form and omitted from generated markdown, even if values were previously entered — same "hidden fields retain their values but don't appear in markdown output" behavior used for `entryType` switching. `generateMarkdown`/`generateCyclingMarkdown`/`generateRestMarkdown`/`generateOtherMarkdown` all take a `showTrainerRoadMetrics` parameter to enforce this.

### Entry Types
Three entry types supported via `entryType` field (default: `cycling`):
- **`cycling`** — full cycling workout form (Core Metrics, Fueling, Performance, Recovery, Reflection)
Expand Down
117 changes: 117 additions & 0 deletions client/src/components/ui/settings-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { useEffect, useRef } from "react";
import { X } from "lucide-react";

const FOCUSABLE_SELECTOR =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';

interface SettingsPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
showTrainerRoadMetrics: boolean;
onShowTrainerRoadMetricsChange: (value: boolean) => void;
}

export function SettingsPanel({
open,
onOpenChange,
showTrainerRoadMetrics,
onShowTrainerRoadMetricsChange,
}: SettingsPanelProps) {
const dialogRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!open) return;

const previouslyFocused = document.activeElement as HTMLElement | null;
const getFocusable = () =>
dialogRef.current
? Array.from(dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
: [];

getFocusable()[0]?.focus();

function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onOpenChange(false);
return;
}
if (e.key !== "Tab") return;

const focusable = getFocusable();
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];

if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}

document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
previouslyFocused?.focus();
};
}, [open, onOpenChange]);

if (!open) return null;

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => onOpenChange(false)}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="settings-panel-title"
className="w-full max-w-sm rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg p-5"
onClick={(e) => e.stopPropagation()}
Comment thread
aidmax marked this conversation as resolved.
>
<div className="flex items-center justify-between mb-4">
<h2 id="settings-panel-title" className="text-lg font-semibold text-gray-900 dark:text-white">
Settings
</h2>
<button
type="button"
onClick={() => onOpenChange(false)}
aria-label="Close settings"
className="p-1 rounded-md text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>

<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">Show TrainerRoad metrics</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Show TR-RPE and TR-LGT fields on the form and in generated markdown.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={showTrainerRoadMetrics}
aria-label="Show TrainerRoad metrics"
onClick={() => onShowTrainerRoadMetricsChange(!showTrainerRoadMetrics)}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
showTrainerRoadMetrics ? "bg-brand-blue" : "bg-gray-300 dark:bg-gray-600"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
showTrainerRoadMetrics ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
</div>
</div>
);
}
76 changes: 76 additions & 0 deletions client/src/hooks/use-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useState } from "react";

export interface Settings {
showTrainerRoadMetrics: boolean;
}

interface PersistedSettings {
version: 1;
data: Settings;
}

interface UseSettingsOptions {
key: string;
defaults: Settings;
}

export function useSettings(options: UseSettingsOptions): {
settings: Settings;
setSetting: <K extends keyof Settings>(key: K, value: Settings[K]) => void;
} {
const { key, defaults } = options;

const [settings, setSettings] = useState<Settings>(() => {
try {
const stored = localStorage.getItem(key);
if (stored) {
const parsed: unknown = JSON.parse(stored);
if (
typeof parsed !== "object" ||
parsed === null ||
(parsed as Record<string, unknown>).version !== 1 ||
typeof (parsed as Record<string, unknown>).data !== "object" ||
(parsed as Record<string, unknown>).data === null
) {
// Malformed or wrong version — fall back to defaults and overwrite
persistRaw(key, defaults);
return { ...defaults };
}
const data = (parsed as PersistedSettings).data;
// Merge: use defaults for missing keys, drop unknown keys
const merged: Settings = { ...defaults };
for (const settingKey of Object.keys(defaults) as (keyof Settings)[]) {
if (typeof data[settingKey] === "boolean") {
merged[settingKey] = data[settingKey];
}
}
return merged;
}
} catch (err) {
console.error("[use-settings] Failed to restore settings:", err);
persistRaw(key, defaults);
}
return { ...defaults };
Comment thread
aidmax marked this conversation as resolved.
});

function setSetting<K extends keyof Settings>(settingKey: K, value: Settings[K]) {
setSettings((prev) => {
const next = { ...prev, [settingKey]: value };
persistRaw(key, next);
return next;
});
}

return { settings, setSetting };
}

function persistRaw(key: string, data: Settings) {
try {
const toStore: PersistedSettings = { version: 1, data };
localStorage.setItem(key, JSON.stringify(toStore));
} catch (err) {
if (err instanceof DOMException && err.name === "QuotaExceededError") {
console.error("[use-settings] localStorage quota exceeded");
}
}
}
Loading
Loading