From d896fe36a8ad35717b1e624b1b31ad69c80269b0 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 26 Feb 2026 22:15:48 -0500 Subject: [PATCH] feat: add TypeScript types, API client, and TanStack Query hooks Co-Authored-By: Claude Opus 4.6 --- TaskTracker.Web/src/api/client.ts | 12 ++++ TaskTracker.Web/src/api/context.ts | 19 ++++++ TaskTracker.Web/src/api/mappings.ts | 36 +++++++++++ TaskTracker.Web/src/api/tasks.ts | 92 +++++++++++++++++++++++++++++ TaskTracker.Web/src/main.tsx | 9 ++- TaskTracker.Web/src/types/index.ts | 71 ++++++++++++++++++++++ 6 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 TaskTracker.Web/src/api/client.ts create mode 100644 TaskTracker.Web/src/api/context.ts create mode 100644 TaskTracker.Web/src/api/mappings.ts create mode 100644 TaskTracker.Web/src/api/tasks.ts create mode 100644 TaskTracker.Web/src/types/index.ts diff --git a/TaskTracker.Web/src/api/client.ts b/TaskTracker.Web/src/api/client.ts new file mode 100644 index 0000000..461e0c5 --- /dev/null +++ b/TaskTracker.Web/src/api/client.ts @@ -0,0 +1,12 @@ +import axios from 'axios' +import type { ApiResponse } from '../types' + +const api = axios.create({ baseURL: '/api' }) + +export async function request(config: Parameters[0]): Promise { + const { data } = await api.request>(config) + if (!data.success) throw new Error(data.error ?? 'API error') + return data.data +} + +export default api diff --git a/TaskTracker.Web/src/api/context.ts b/TaskTracker.Web/src/api/context.ts new file mode 100644 index 0000000..e41f26f --- /dev/null +++ b/TaskTracker.Web/src/api/context.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query' +import { request } from './client' +import type { ContextEvent, ContextSummaryItem } from '../types' + +export function useRecentContext(minutes = 30) { + return useQuery({ + queryKey: ['context', 'recent', minutes], + queryFn: () => request({ url: '/context/recent', params: { minutes } }), + refetchInterval: 60_000, + }) +} + +export function useContextSummary() { + return useQuery({ + queryKey: ['context', 'summary'], + queryFn: () => request({ url: '/context/summary' }), + refetchInterval: 60_000, + }) +} diff --git a/TaskTracker.Web/src/api/mappings.ts b/TaskTracker.Web/src/api/mappings.ts new file mode 100644 index 0000000..e4bc4ca --- /dev/null +++ b/TaskTracker.Web/src/api/mappings.ts @@ -0,0 +1,36 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { request } from './client' +import type { AppMapping } from '../types' + +export function useMappings() { + return useQuery({ + queryKey: ['mappings'], + queryFn: () => request({ url: '/mappings' }), + }) +} + +export function useCreateMapping() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: { pattern: string; matchType: string; category: string; friendlyName?: string }) => + request({ method: 'POST', url: '/mappings', data: body }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['mappings'] }), + }) +} + +export function useUpdateMapping() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, ...body }: { id: number; pattern: string; matchType: string; category: string; friendlyName?: string }) => + request({ method: 'PUT', url: `/mappings/${id}`, data: body }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['mappings'] }), + }) +} + +export function useDeleteMapping() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: number) => request({ method: 'DELETE', url: `/mappings/${id}` }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['mappings'] }), + }) +} diff --git a/TaskTracker.Web/src/api/tasks.ts b/TaskTracker.Web/src/api/tasks.ts new file mode 100644 index 0000000..3e62ab6 --- /dev/null +++ b/TaskTracker.Web/src/api/tasks.ts @@ -0,0 +1,92 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { request } from './client' +import type { WorkTask } from '../types' + +export function useTasks(includeSubTasks = true) { + return useQuery({ + queryKey: ['tasks', { includeSubTasks }], + queryFn: () => request({ url: '/tasks', params: { includeSubTasks } }), + }) +} + +export function useActiveTask() { + return useQuery({ + queryKey: ['tasks', 'active'], + queryFn: () => request({ url: '/tasks/active' }), + refetchInterval: 30_000, + }) +} + +export function useTask(id: number) { + return useQuery({ + queryKey: ['tasks', id], + queryFn: () => request({ url: `/tasks/${id}` }), + }) +} + +function useInvalidateTasks() { + const qc = useQueryClient() + return () => { + qc.invalidateQueries({ queryKey: ['tasks'] }) + } +} + +export function useCreateTask() { + const invalidate = useInvalidateTasks() + return useMutation({ + mutationFn: (body: { title: string; description?: string; category?: string; parentTaskId?: number; estimatedMinutes?: number }) => + request({ method: 'POST', url: '/tasks', data: body }), + onSuccess: invalidate, + }) +} + +export function useUpdateTask() { + const invalidate = useInvalidateTasks() + return useMutation({ + mutationFn: ({ id, ...body }: { id: number; title?: string; description?: string; category?: string; estimatedMinutes?: number }) => + request({ method: 'PUT', url: `/tasks/${id}`, data: body }), + onSuccess: invalidate, + }) +} + +export function useStartTask() { + const invalidate = useInvalidateTasks() + return useMutation({ + mutationFn: (id: number) => request({ method: 'PUT', url: `/tasks/${id}/start` }), + onSuccess: invalidate, + }) +} + +export function usePauseTask() { + const invalidate = useInvalidateTasks() + return useMutation({ + mutationFn: ({ id, note }: { id: number; note?: string }) => + request({ method: 'PUT', url: `/tasks/${id}/pause`, data: { note } }), + onSuccess: invalidate, + }) +} + +export function useResumeTask() { + const invalidate = useInvalidateTasks() + return useMutation({ + mutationFn: ({ id, note }: { id: number; note?: string }) => + request({ method: 'PUT', url: `/tasks/${id}/resume`, data: { note } }), + onSuccess: invalidate, + }) +} + +export function useCompleteTask() { + const invalidate = useInvalidateTasks() + return useMutation({ + mutationFn: (id: number) => request({ method: 'PUT', url: `/tasks/${id}/complete` }), + onSuccess: invalidate, + }) +} + +export function useAbandonTask() { + const invalidate = useInvalidateTasks() + return useMutation({ + mutationFn: (id: number) => request({ method: 'DELETE', url: `/tasks/${id}` }), + onSuccess: invalidate, + }) +} diff --git a/TaskTracker.Web/src/main.tsx b/TaskTracker.Web/src/main.tsx index db032b7..de4801c 100644 --- a/TaskTracker.Web/src/main.tsx +++ b/TaskTracker.Web/src/main.tsx @@ -1,10 +1,17 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import './index.css' import App from './App' +const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: 10_000, retry: 1 } }, +}) + createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/TaskTracker.Web/src/types/index.ts b/TaskTracker.Web/src/types/index.ts new file mode 100644 index 0000000..b601efb --- /dev/null +++ b/TaskTracker.Web/src/types/index.ts @@ -0,0 +1,71 @@ +export const WorkTaskStatus = { + Pending: 0, + Active: 1, + Paused: 2, + Completed: 3, + Abandoned: 4, +} as const +export type WorkTaskStatus = (typeof WorkTaskStatus)[keyof typeof WorkTaskStatus] + +export const NoteType = { + PauseNote: 0, + ResumeNote: 1, + General: 2, +} as const +export type NoteType = (typeof NoteType)[keyof typeof NoteType] + +export interface WorkTask { + id: number + title: string + description: string | null + status: WorkTaskStatus + category: string | null + createdAt: string + startedAt: string | null + completedAt: string | null + estimatedMinutes: number | null + parentTaskId: number | null + subTasks: WorkTask[] + notes: TaskNote[] + contextEvents: ContextEvent[] +} + +export interface TaskNote { + id: number + workTaskId: number + content: string + type: NoteType + createdAt: string +} + +export interface ContextEvent { + id: number + workTaskId: number | null + source: string + appName: string + windowTitle: string + url: string | null + timestamp: string +} + +export interface AppMapping { + id: number + pattern: string + matchType: string + category: string + friendlyName: string | null +} + +export interface ContextSummaryItem { + appName: string + category: string + eventCount: number + firstSeen: string + lastSeen: string +} + +export interface ApiResponse { + success: boolean + data: T + error: string | null +}