diff --git a/apps/backend/src/db/activities.integration.test.ts b/apps/backend/src/db/activities.integration.test.ts index dbf61c9..7bc59f2 100644 --- a/apps/backend/src/db/activities.integration.test.ts +++ b/apps/backend/src/db/activities.integration.test.ts @@ -526,5 +526,93 @@ describe('Activities Integration Tests', () => { expect(updated).not.toBeNull() expect(updated?.title).toBe('Morning meditation') }) + + test('updates data field on activity', async () => { + const user = getTestUser() + const activityId = randomUUID() + + await insertActivity(user, { + activity_type: 'exercise', + end_time: new Date('2024-01-15T11:00:00Z'), + id: activityId, + source: 'manual', + start_time: new Date('2024-01-15T10:00:00Z'), + }) + + const updated = await updateActivity(user, activityId, { + data: { exerciseType: 81, exerciseTypeName: 'weightlifting' }, + }) + + expect(updated).not.toBeNull() + expect(updated?.data).toEqual({ exerciseType: 81, exerciseTypeName: 'weightlifting' }) + }) + + test('replaces entire data field (no partial merge at db level)', async () => { + const user = getTestUser() + const activityId = randomUUID() + + await insertActivity(user, { + activity_type: 'exercise', + data: { calories: 300, exerciseType: 70, exerciseTypeName: 'strength_training' }, + end_time: new Date('2024-01-15T11:00:00Z'), + id: activityId, + source: 'manual', + start_time: new Date('2024-01-15T10:00:00Z'), + }) + + // DB layer replaces data entirely; merging is done in the service layer + const updated = await updateActivity(user, activityId, { + data: { exerciseType: 81, exerciseTypeName: 'weightlifting' }, + }) + + expect(updated).not.toBeNull() + expect(updated?.data).toEqual({ exerciseType: 81, exerciseTypeName: 'weightlifting' }) + }) + + test('preserves data when updating other fields', async () => { + const user = getTestUser() + const activityId = randomUUID() + + await insertActivity(user, { + activity_type: 'exercise', + data: { exerciseType: 81, exerciseTypeName: 'weightlifting' }, + end_time: new Date('2024-01-15T11:00:00Z'), + id: activityId, + source: 'manual', + start_time: new Date('2024-01-15T10:00:00Z'), + }) + + const updated = await updateActivity(user, activityId, { + title: 'Heavy lifting session', + }) + + expect(updated).not.toBeNull() + expect(updated?.title).toBe('Heavy lifting session') + expect(updated?.data).toEqual({ exerciseType: 81, exerciseTypeName: 'weightlifting' }) + }) + + test('updates data and other fields together', async () => { + const user = getTestUser() + const activityId = randomUUID() + + await insertActivity(user, { + activity_type: 'exercise', + end_time: new Date('2024-01-15T11:00:00Z'), + id: activityId, + source: 'manual', + start_time: new Date('2024-01-15T10:00:00Z'), + }) + + const updated = await updateActivity(user, activityId, { + data: { exerciseType: 56, exerciseTypeName: 'running' }, + notes: 'Morning run in the park', + title: 'Morning Run', + }) + + expect(updated).not.toBeNull() + expect(updated?.title).toBe('Morning Run') + expect(updated?.notes).toBe('Morning run in the park') + expect(updated?.data).toEqual({ exerciseType: 56, exerciseTypeName: 'running' }) + }) }) }) 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.test.ts b/apps/backend/src/mcp.test.ts index 35e0b7a..41c105e 100644 --- a/apps/backend/src/mcp.test.ts +++ b/apps/backend/src/mcp.test.ts @@ -24,9 +24,12 @@ vi.mock('./services/mutations', () => ({ addCustomMetric: vi.fn(), addMetric: vi.fn(), addTag: vi.fn(), + deleteActivity: vi.fn(), deleteCustomMetric: vi.fn(), deleteTag: vi.fn(), getCustomMetrics: vi.fn().mockResolvedValue([]), + restoreActivity: vi.fn(), + updateActivity: vi.fn(), })) // Mock db for sync status and stored detected locations @@ -1182,6 +1185,202 @@ describe('MCP Server', () => { }) }) + describe('Tool: update_activity', () => { + async function initializeSession(app: express.Express, token: string) { + const response = await mcpPost(app) + .set('Authorization', `Bearer ${token}`) + .send({ + id: 1, + jsonrpc: '2.0', + method: 'initialize', + params: { + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' }, + protocolVersion: '2024-11-05', + }, + }) + return response.headers['mcp-session-id'] as string + } + + async function callTool( + app: express.Express, + token: string, + sessionId: string, + toolName: string, + args: Record, + ) { + const response = await mcpPost(app) + .set('Authorization', `Bearer ${token}`) + .set('Mcp-Session-Id', sessionId) + .send({ + id: 2, + jsonrpc: '2.0', + method: 'tools/call', + params: { arguments: args, name: toolName }, + }) + + const parsed = parseSSEResponse(response.text) as { result: { content: { text: string }[] } } + return { + ...response, + parsed, + toolResult: JSON.parse(parsed.result.content[0].text), + } + } + + const testActivityId = '00000000-0000-4000-a000-000000000001' + + test('updates activity with exercise_type', async () => { + const app = createTestApp() + const token = auth.createToken('testuser') + const sessionId = await initializeSession(app, token) + + vi.mocked(mutations.updateActivity).mockResolvedValue({ + activity_type: 'exercise', + end_time: '2024-03-15T11:00:00.000Z', + id: testActivityId, + start_time: '2024-03-15T10:00:00.000Z', + success: true, + title: 'Workout', + }) + + const response = await callTool(app, token, sessionId, 'update_activity', { + exercise_type: 'weightlifting', + id: testActivityId, + title: 'Workout', + }) + + expect(response.status).toBe(200) + expect(response.toolResult.success).toBe(true) + expect(mutations.updateActivity).toHaveBeenCalledWith('testuser', testActivityId, { + data: { + exerciseType: 81, + exerciseTypeName: 'weightlifting', + }, + end_time: undefined, + notes: undefined, + start_time: undefined, + title: 'Workout', + }) + }) + + test('updates activity without exercise_type', async () => { + const app = createTestApp() + const token = auth.createToken('testuser') + const sessionId = await initializeSession(app, token) + + vi.mocked(mutations.updateActivity).mockResolvedValue({ + activity_type: 'exercise', + end_time: '2024-03-15T11:00:00.000Z', + id: testActivityId, + notes: 'Great session', + start_time: '2024-03-15T10:00:00.000Z', + success: true, + }) + + const response = await callTool(app, token, sessionId, 'update_activity', { + id: testActivityId, + notes: 'Great session', + }) + + expect(response.status).toBe(200) + expect(response.toolResult.success).toBe(true) + expect(mutations.updateActivity).toHaveBeenCalledWith('testuser', testActivityId, { + data: undefined, + end_time: undefined, + notes: 'Great session', + start_time: undefined, + title: undefined, + }) + }) + + test('returns error for invalid exercise_type', async () => { + const app = createTestApp() + const token = auth.createToken('testuser') + const sessionId = await initializeSession(app, token) + + const response = await mcpPost(app) + .set('Authorization', `Bearer ${token}`) + .set('Mcp-Session-Id', sessionId) + .send({ + id: 2, + jsonrpc: '2.0', + method: 'tools/call', + params: { + arguments: { + exercise_type: 'not_a_real_exercise', + id: testActivityId, + }, + name: 'update_activity', + }, + }) + + expect(response.status).toBe(200) + const parsed = parseSSEResponse(response.text) as { result: { content: { text: string }[] } } + expect(parsed.result.content[0].text).toContain('Invalid exercise_type') + expect(mutations.updateActivity).not.toHaveBeenCalled() + }) + + test('passes time updates as Date objects', async () => { + const app = createTestApp() + const token = auth.createToken('testuser') + const sessionId = await initializeSession(app, token) + + vi.mocked(mutations.updateActivity).mockResolvedValue({ + activity_type: 'exercise', + end_time: '2024-03-15T12:00:00.000Z', + id: testActivityId, + start_time: '2024-03-15T09:00:00.000Z', + success: true, + }) + + await callTool(app, token, sessionId, 'update_activity', { + end_time: '2024-03-15T12:00:00Z', + id: testActivityId, + start_time: '2024-03-15T09:00:00Z', + }) + + expect(mutations.updateActivity).toHaveBeenCalledWith('testuser', testActivityId, { + data: undefined, + end_time: expect.any(Date), + notes: undefined, + start_time: expect.any(Date), + title: undefined, + }) + }) + + test('returns error from service on failure', async () => { + const app = createTestApp() + const token = auth.createToken('testuser') + const sessionId = await initializeSession(app, token) + + vi.mocked(mutations.updateActivity).mockResolvedValue({ + error: 'Activity not found', + id: testActivityId, + success: false, + }) + + const response = await mcpPost(app) + .set('Authorization', `Bearer ${token}`) + .set('Mcp-Session-Id', sessionId) + .send({ + id: 2, + jsonrpc: '2.0', + method: 'tools/call', + params: { + arguments: { + id: testActivityId, + title: 'New title', + }, + name: 'update_activity', + }, + }) + + expect(response.status).toBe(200) + const parsed = parseSSEResponse(response.text) as { result: { content: { text: string }[] } } + expect(parsed.result.content[0].text).toContain('Activity not found') + }) + }) + describe('Session Persistence', () => { function createTestAppWithStore(sessionStore: McpSessionStore) { const app = express() 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.test.ts b/apps/backend/src/services/mutations.test.ts index 0026e37..d14560f 100644 --- a/apps/backend/src/services/mutations.test.ts +++ b/apps/backend/src/services/mutations.test.ts @@ -839,6 +839,166 @@ describe('updateActivity', () => { expect(result.error).toBe('end_time must be after start_time') expect(db.updateActivity).not.toHaveBeenCalled() }) + + test('merges data with existing activity data', async () => { + vi.mocked(db.getActivityById).mockResolvedValue({ + activity_type: 'exercise', + data: { exerciseType: 70, exerciseTypeName: 'strength_training' }, + end_time: new Date('2024-03-15T11:00:00Z'), + id: 'activity-123', + source: 'manual', + start_time: new Date('2024-03-15T10:00:00Z'), + }) + vi.mocked(db.updateActivity).mockResolvedValue({ + activity_type: 'exercise', + data: { exerciseType: 81, exerciseTypeName: 'weightlifting' }, + end_time: new Date('2024-03-15T11:00:00Z'), + id: 'activity-123', + source: 'manual', + start_time: new Date('2024-03-15T10:00:00Z'), + }) + + await updateActivity('testuser', 'activity-123', { + data: { exerciseType: 81, exerciseTypeName: 'weightlifting' }, + }) + + expect(db.updateActivity).toHaveBeenCalledWith('testuser', 'activity-123', { + data: { exerciseType: 81, exerciseTypeName: 'weightlifting' }, + end_time: undefined, + notes: undefined, + start_time: undefined, + title: undefined, + }) + }) + + test('merges new data fields while preserving existing ones', async () => { + vi.mocked(db.getActivityById).mockResolvedValue({ + activity_type: 'exercise', + data: { calories: 300, exerciseType: 70, exerciseTypeName: 'strength_training' }, + end_time: new Date('2024-03-15T11:00:00Z'), + id: 'activity-123', + source: 'manual', + start_time: new Date('2024-03-15T10:00:00Z'), + }) + vi.mocked(db.updateActivity).mockResolvedValue({ + activity_type: 'exercise', + data: { calories: 300, exerciseType: 81, exerciseTypeName: 'weightlifting' }, + end_time: new Date('2024-03-15T11:00:00Z'), + id: 'activity-123', + source: 'manual', + start_time: new Date('2024-03-15T10:00:00Z'), + }) + + await updateActivity('testuser', 'activity-123', { + data: { exerciseType: 81, exerciseTypeName: 'weightlifting' }, + }) + + // Should merge: existing {calories, exerciseType, exerciseTypeName} + new {exerciseType, exerciseTypeName} + expect(db.updateActivity).toHaveBeenCalledWith('testuser', 'activity-123', { + data: { calories: 300, exerciseType: 81, exerciseTypeName: 'weightlifting' }, + end_time: undefined, + notes: undefined, + start_time: undefined, + title: undefined, + }) + }) + + test('sets data on activity with no existing data', async () => { + vi.mocked(db.getActivityById).mockResolvedValue({ + activity_type: 'exercise', + end_time: new Date('2024-03-15T11:00:00Z'), + id: 'activity-123', + source: 'manual', + start_time: new Date('2024-03-15T10:00:00Z'), + }) + vi.mocked(db.updateActivity).mockResolvedValue({ + activity_type: 'exercise', + data: { exerciseType: 56, exerciseTypeName: 'running' }, + end_time: new Date('2024-03-15T11:00:00Z'), + id: 'activity-123', + source: 'manual', + start_time: new Date('2024-03-15T10:00:00Z'), + }) + + await updateActivity('testuser', 'activity-123', { + data: { exerciseType: 56, exerciseTypeName: 'running' }, + }) + + expect(db.updateActivity).toHaveBeenCalledWith('testuser', 'activity-123', { + data: { exerciseType: 56, exerciseTypeName: 'running' }, + end_time: undefined, + notes: undefined, + start_time: undefined, + title: undefined, + }) + }) + + test('does not pass data when input has no data field', async () => { + vi.mocked(db.getActivityById).mockResolvedValue({ + activity_type: 'exercise', + data: { exerciseType: 70 }, + end_time: new Date('2024-03-15T11:00:00Z'), + id: 'activity-123', + source: 'manual', + start_time: new Date('2024-03-15T10:00:00Z'), + }) + vi.mocked(db.updateActivity).mockResolvedValue({ + activity_type: 'exercise', + data: { exerciseType: 70 }, + end_time: new Date('2024-03-15T11:00:00Z'), + id: 'activity-123', + notes: 'Updated notes', + source: 'manual', + start_time: new Date('2024-03-15T10:00:00Z'), + }) + + await updateActivity('testuser', 'activity-123', { + notes: 'Updated notes', + }) + + // data should be undefined (not touched) when not provided in input + expect(db.updateActivity).toHaveBeenCalledWith('testuser', 'activity-123', { + data: undefined, + end_time: undefined, + notes: 'Updated notes', + start_time: undefined, + title: undefined, + }) + }) + + test('updates data and other fields together', async () => { + vi.mocked(db.getActivityById).mockResolvedValue({ + activity_type: 'exercise', + end_time: new Date('2024-03-15T11:00:00Z'), + id: 'activity-123', + source: 'manual', + start_time: new Date('2024-03-15T10:00:00Z'), + }) + vi.mocked(db.updateActivity).mockResolvedValue({ + activity_type: 'exercise', + data: { exerciseType: 81, exerciseTypeName: 'weightlifting' }, + end_time: new Date('2024-03-15T11:00:00Z'), + id: 'activity-123', + source: 'manual', + start_time: new Date('2024-03-15T10:00:00Z'), + title: 'Heavy lifting', + }) + + const result = await updateActivity('testuser', 'activity-123', { + data: { exerciseType: 81, exerciseTypeName: 'weightlifting' }, + title: 'Heavy lifting', + }) + + expect(result.success).toBe(true) + expect(result.title).toBe('Heavy lifting') + expect(db.updateActivity).toHaveBeenCalledWith('testuser', 'activity-123', { + data: { exerciseType: 81, exerciseTypeName: 'weightlifting' }, + end_time: undefined, + notes: undefined, + start_time: undefined, + title: 'Heavy lifting', + }) + }) }) describe('updateCustomMetric', () => { 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)} + /> +
+
+ +
+ +