From 863f0cb503cfe5227a4dc58b3b9219beb97559f4 Mon Sep 17 00:00:00 2001 From: Fredrik Liljegren Date: Tue, 24 Feb 2026 22:18:03 +0100 Subject: [PATCH 1/3] Add web UI for creating data and editing exercise types - Add new "Add Data" page with forms for activities, tags, and metrics - Add custom metrics management (CRUD) to Settings page - Show exercise type in exercise detail view (read + edit mode) - Add exercise_type support to updateActivity API (api-spec, backend, MCP) - Add API functions: addActivity, addTag, addMetric, addCustomMetric, updateCustomMetric, deleteCustomMetric - Add "+ Add" navigation link in header Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- apps/backend/src/db/activities.ts | 1 + apps/backend/src/db/types.ts | 1 + apps/backend/src/mcp/activity-tools.ts | 25 +- apps/backend/src/routes/activities-router.ts | 18 +- apps/backend/src/services/mutations.ts | 6 + .../src/components/CustomMetricsSettings.tsx | 241 +++++++++++ apps/web/src/components/Header.tsx | 3 + apps/web/src/index.tsx | 2 + apps/web/src/pages/AddData/index.tsx | 395 ++++++++++++++++++ apps/web/src/pages/AddData/style.css | 221 ++++++++++ .../EntityDetail/EditableActivityFields.tsx | 1 + .../src/pages/EntityDetail/ExerciseDetail.tsx | 36 ++ apps/web/src/pages/EntityDetail/index.tsx | 15 +- apps/web/src/pages/Settings/index.tsx | 3 + apps/web/src/pages/Settings/style.css | 126 ++++++ apps/web/src/state/api.ts | 84 +++- packages/api-spec/src/schemas/activities.ts | 3 + 17 files changed, 1176 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/CustomMetricsSettings.tsx create mode 100644 apps/web/src/pages/AddData/index.tsx create mode 100644 apps/web/src/pages/AddData/style.css diff --git a/apps/backend/src/db/activities.ts b/apps/backend/src/db/activities.ts index 30b76b0..320b578 100644 --- a/apps/backend/src/db/activities.ts +++ b/apps/backend/src/db/activities.ts @@ -232,6 +232,7 @@ export const updateActivity = async ( if (updates.end_time !== undefined) fields.push({ column: 'end_time', value: updates.end_time }) if (updates.title !== undefined) fields.push({ column: 'title', value: updates.title }) if (updates.notes !== undefined) fields.push({ column: 'notes', value: updates.notes }) + if (updates.data !== undefined) fields.push({ column: 'data', value: JSON.stringify(updates.data) }) if (fields.length === 0) return getActivityById(user, id) diff --git a/apps/backend/src/db/types.ts b/apps/backend/src/db/types.ts index a8c1bcf..edf9230 100644 --- a/apps/backend/src/db/types.ts +++ b/apps/backend/src/db/types.ts @@ -89,6 +89,7 @@ export interface ActivityUpdate { end_time?: Date title?: string notes?: string + data?: Record } // ============================================================================ diff --git a/apps/backend/src/mcp/activity-tools.ts b/apps/backend/src/mcp/activity-tools.ts index 7379449..729458f 100644 --- a/apps/backend/src/mcp/activity-tools.ts +++ b/apps/backend/src/mcp/activity-tools.ts @@ -87,13 +87,34 @@ export const registerActivityTools = (server: McpServer, user: string) => { // Tool: update_activity server.tool( 'update_activity', - 'Update an existing activity. Can modify start_time, end_time, title, and notes. Only provided fields will be updated. Validates that end_time is after start_time (considering both new and existing values).', + 'Update an existing activity. Can modify start_time, end_time, title, notes, and exercise_type. Only provided fields will be updated. Validates that end_time is after start_time (considering both new and existing values).', { id: z.string().uuid().describe('The ID of the activity to update'), ...updateActivityBodySchema.shape, + // Override enum with z.string() to allow handler-level validation with friendlier error message + exercise_type: z + .string() + .optional() + .describe( + `New exercise type name (e.g., "weightlifting", "running"). Only for exercise activities. Valid types: ${exerciseTypeNames.slice(0, 10).join(', ')}...`, + ), }, - async ({ id, start_time, end_time, title, notes }) => { + async ({ id, start_time, end_time, title, notes, exercise_type }) => { + let data: Record | undefined + if (exercise_type !== undefined) { + if (!isValidExerciseType(exercise_type)) { + return errorResponse( + `Invalid exercise_type "${exercise_type}". Valid types include: ${exerciseTypeNames.slice(0, 15).join(', ')}...`, + ) + } + data = { + exerciseType: getExerciseTypeValue(exercise_type), + exerciseTypeName: exercise_type, + } + } + const result = await updateActivity(user, id, { + data, end_time: end_time ? new Date(end_time) : undefined, notes, start_time: start_time ? new Date(start_time) : undefined, diff --git a/apps/backend/src/routes/activities-router.ts b/apps/backend/src/routes/activities-router.ts index 71f2594..02bf20c 100644 --- a/apps/backend/src/routes/activities-router.ts +++ b/apps/backend/src/routes/activities-router.ts @@ -140,10 +140,26 @@ export const createActivitiesRouter = ( validateBody(updateActivityBodySchema), async (req, res) => { const { id } = req.params - const { start_time, end_time, title, notes } = req.body + const { start_time, end_time, title, notes, exercise_type } = req.body const user = req.user! + // Convert exercise_type name to data object if provided + let data: Record | undefined + if (exercise_type !== undefined) { + if (!isValidExerciseType(exercise_type)) { + return res.status(400).json({ + error: `Invalid exercise_type "${exercise_type}"`, + success: false, + }) + } + data = { + exerciseType: getExerciseTypeValue(exercise_type), + exerciseTypeName: exercise_type, + } + } + const result = await updateActivity(user, id, { + data, end_time: end_time ? new Date(end_time) : undefined, notes, start_time: start_time ? new Date(start_time) : undefined, diff --git a/apps/backend/src/services/mutations.ts b/apps/backend/src/services/mutations.ts index b4a7a71..348429f 100644 --- a/apps/backend/src/services/mutations.ts +++ b/apps/backend/src/services/mutations.ts @@ -145,6 +145,7 @@ export interface UpdateActivityInput { end_time?: Date title?: string notes?: string + data?: Record } export interface UpdateActivityResult { @@ -514,7 +515,12 @@ export async function updateActivity( } } + // Merge new data fields into existing data (preserving fields not being updated) + const mergedData = + input.data ? { ...((existing.data as Record) ?? {}), ...input.data } : undefined + const updated = await dbUpdateActivity(user, id, { + data: mergedData, end_time: input.end_time, notes: input.notes, start_time: input.start_time, diff --git a/apps/web/src/components/CustomMetricsSettings.tsx b/apps/web/src/components/CustomMetricsSettings.tsx new file mode 100644 index 0000000..42c5b57 --- /dev/null +++ b/apps/web/src/components/CustomMetricsSettings.tsx @@ -0,0 +1,241 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'preact/hooks' +import { + addCustomMetric, + deleteCustomMetric, + fetchCustomMetrics, + updateCustomMetric, + type CustomMetricDefinition, +} from '../state/api' + +const CustomMetricRow = ({ + metric, + onDeleted, + onUpdated, +}: { + metric: CustomMetricDefinition + onDeleted: () => void + onUpdated: () => void +}) => { + const [isEditing, setIsEditing] = useState(false) + const [unit, setUnit] = useState(metric.unit) + const [description, setDescription] = useState(metric.description ?? '') + const [minValue, setMinValue] = useState(metric.min_value?.toString() ?? '') + const [maxValue, setMaxValue] = useState(metric.max_value?.toString() ?? '') + + const updateMutation = useMutation({ + mutationFn: () => + updateCustomMetric(metric.name, { + description: description || undefined, + max_value: maxValue ? parseFloat(maxValue) : null, + min_value: minValue ? parseFloat(minValue) : null, + unit, + }), + onSuccess: () => { + setIsEditing(false) + onUpdated() + }, + }) + + const deleteMutation = useMutation({ + mutationFn: () => deleteCustomMetric(metric.name), + onSuccess: onDeleted, + }) + + if (isEditing) { + return ( +
+
+ {metric.name} +
+ setUnit((e.target as HTMLInputElement).value)} + placeholder="Unit" + class="custom-metric-input" + /> + setDescription((e.target as HTMLInputElement).value)} + placeholder="Description" + class="custom-metric-input wide" + /> +
+
+ setMinValue((e.target as HTMLInputElement).value)} + placeholder="Min" + class="custom-metric-input" + /> + setMaxValue((e.target as HTMLInputElement).value)} + placeholder="Max" + class="custom-metric-input" + /> +
+
+
+ + +
+
+ ) + } + + return ( +
+
+ {metric.name} + {metric.unit} + {metric.description && {metric.description}} + {(metric.min_value !== undefined || metric.max_value !== undefined) && ( + + Range: {metric.min_value ?? '—'} – {metric.max_value ?? '—'} + + )} +
+
+ + +
+
+ ) +} + +export function CustomMetricsSettings() { + const queryClient = useQueryClient() + const [newName, setNewName] = useState('') + const [newUnit, setNewUnit] = useState('') + const [newDescription, setNewDescription] = useState('') + const [newMinValue, setNewMinValue] = useState('') + const [newMaxValue, setNewMaxValue] = useState('') + + const { data: metrics } = useQuery({ + queryFn: fetchCustomMetrics, + queryKey: ['customMetrics'], + staleTime: 5 * 60 * 1000, + }) + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: ['customMetrics'] }) + } + + const addMutation = useMutation({ + mutationFn: () => + addCustomMetric({ + ...(newDescription ? { description: newDescription } : {}), + ...(newMaxValue ? { max_value: parseFloat(newMaxValue) } : {}), + ...(newMinValue ? { min_value: parseFloat(newMinValue) } : {}), + name: newName, + unit: newUnit, + }), + onSuccess: () => { + setNewName('') + setNewUnit('') + setNewDescription('') + setNewMinValue('') + setNewMaxValue('') + invalidate() + }, + }) + + return ( +
+

Custom Metrics

+

+ Define custom metrics to track any numeric data. Custom metrics appear in the metric picker and can be + used in trends and dashboards. +

+ + {(metrics ?? []).length > 0 && ( +
+ {(metrics ?? []).map((m) => ( + + ))} +
+ )} + +
+

Add Custom Metric

+
+ setNewName((e.target as HTMLInputElement).value)} + placeholder="name (snake_case)" + class="custom-metric-input" + /> + setNewUnit((e.target as HTMLInputElement).value)} + placeholder="unit (e.g. kg, mg, count)" + class="custom-metric-input" + /> +
+
+ setNewDescription((e.target as HTMLInputElement).value)} + placeholder="Description (optional)" + class="custom-metric-input wide" + /> +
+
+ setNewMinValue((e.target as HTMLInputElement).value)} + placeholder="Min value (optional)" + class="custom-metric-input" + /> + setNewMaxValue((e.target as HTMLInputElement).value)} + placeholder="Max value (optional)" + class="custom-metric-input" + /> +
+ {addMutation.isError &&

{(addMutation.error as Error).message}

} + +
+
+ ) +} diff --git a/apps/web/src/components/Header.tsx b/apps/web/src/components/Header.tsx index ee65939..ab4e506 100644 --- a/apps/web/src/components/Header.tsx +++ b/apps/web/src/components/Header.tsx @@ -31,6 +31,9 @@ export function Header() { Day + + + Add + Trends diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index 7a50593..5392bbe 100644 --- a/apps/web/src/index.tsx +++ b/apps/web/src/index.tsx @@ -4,6 +4,7 @@ import { render } from 'preact' import { LocationProvider, Route, Router } from 'preact-iso' import { Footer } from './components/Footer.jsx' import { Header } from './components/Header.jsx' +import { AddData } from './pages/AddData/index.jsx' import { AdminSettings } from './pages/AdminSettings/index.jsx' import { Correlations } from './pages/Correlations/index.jsx' import { DayView } from './pages/DayView/index.jsx' @@ -37,6 +38,7 @@ export function App() { + diff --git a/apps/web/src/pages/AddData/index.tsx b/apps/web/src/pages/AddData/index.tsx new file mode 100644 index 0000000..0feff56 --- /dev/null +++ b/apps/web/src/pages/AddData/index.tsx @@ -0,0 +1,395 @@ +import { exerciseTypeNames, type ExerciseTypeName } from '@aurboda/api-spec' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { format } from 'date-fns' +import { useCallback, useState } from 'preact/hooks' +import { MetricPicker } from '../../components/MetricPicker' +import { addActivity, addMetric, addTag, fetchUniqueTags, type ActivityType } from '../../state/api' + +import './style.css' + +type Tab = 'activity' | 'tag' | 'metric' + +const nowLocal = () => format(new Date(), "yyyy-MM-dd'T'HH:mm") + +const AddActivityForm = () => { + const queryClient = useQueryClient() + const [activityType, setActivityType] = useState('exercise') + const [exerciseType, setExerciseType] = useState('other_workout') + const [title, setTitle] = useState('') + const [startTime, setStartTime] = useState(nowLocal()) + const [endTime, setEndTime] = useState(nowLocal()) + const [notes, setNotes] = useState('') + const [success, setSuccess] = useState('') + const [error, setError] = useState('') + + const mutation = useMutation({ + mutationFn: () => + addActivity({ + activity_type: activityType, + end_time: new Date(endTime).toISOString(), + ...(activityType === 'exercise' ? { exercise_type: exerciseType } : {}), + ...(notes ? { notes } : {}), + start_time: new Date(startTime).toISOString(), + ...(title ? { title } : {}), + }), + onError: (err: Error) => { + setError(err.message) + setSuccess('') + }, + onSuccess: () => { + setSuccess('Activity added') + setError('') + setTitle('') + setNotes('') + setStartTime(nowLocal()) + setEndTime(nowLocal()) + queryClient.invalidateQueries({ queryKey: ['dayview-activities'] }) + }, + }) + + return ( +
+ {success &&
{success}
} + {error &&
{error}
} + +
+ + +
+ + {activityType === 'exercise' && ( +
+ + +
+ )} + +
+ + setTitle((e.target as HTMLInputElement).value)} + placeholder={activityType === 'exercise' ? 'e.g. Morning run' : 'Optional title'} + /> +
+ +
+
+ + setStartTime((e.target as HTMLInputElement).value)} + /> +
+
+ + setEndTime((e.target as HTMLInputElement).value)} + /> +
+
+ +
+ +