feat(web): remove React app — migration to Razor Pages complete

Delete the entire TaskTracker.Web React/npm frontend. All UI
functionality has been reimplemented as Razor Pages served from
the TaskTracker.Api project using htmx, SortableJS, and Chart.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 22:40:03 -05:00
parent a6adaea2da
commit cffd09941a
37 changed files with 0 additions and 7997 deletions

View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,73 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -1,16 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<title>TaskTracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,41 +0,0 @@
{
"name": "tasktracker-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.5",
"framer-motion": "^12.34.3",
"lucide-react": "^0.575.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"recharts": "^3.7.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,20 +0,0 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import Layout from './components/Layout'
import Board from './pages/Board'
import Analytics from './pages/Analytics'
import Mappings from './pages/Mappings'
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Navigate to="/board" replace />} />
<Route path="/board" element={<Board />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/mappings" element={<Mappings />} />
</Route>
</Routes>
</BrowserRouter>
)
}

View File

@@ -1,12 +0,0 @@
import axios from 'axios'
import type { ApiResponse } from '../types'
const api = axios.create({ baseURL: '/api' })
export async function request<T>(config: Parameters<typeof api.request>[0]): Promise<T> {
const { data } = await api.request<ApiResponse<T>>(config)
if (!data.success) throw new Error(data.error ?? 'API error')
return data.data
}
export default api

View File

@@ -1,19 +0,0 @@
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<ContextEvent[]>({ url: '/context/recent', params: { minutes } }),
refetchInterval: 60_000,
})
}
export function useContextSummary() {
return useQuery({
queryKey: ['context', 'summary'],
queryFn: () => request<ContextSummaryItem[]>({ url: '/context/summary' }),
refetchInterval: 60_000,
})
}

View File

@@ -1,36 +0,0 @@
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<AppMapping[]>({ url: '/mappings' }),
})
}
export function useCreateMapping() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { pattern: string; matchType: string; category: string; friendlyName?: string }) =>
request<AppMapping>({ 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<AppMapping>({ method: 'PUT', url: `/mappings/${id}`, data: body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['mappings'] }),
})
}
export function useDeleteMapping() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => request<void>({ method: 'DELETE', url: `/mappings/${id}` }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['mappings'] }),
})
}

View File

@@ -1,12 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { request } from './client'
import type { TaskNote } from '../types'
export function useCreateNote() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ taskId, content, type }: { taskId: number; content: string; type: string }) =>
request<TaskNote>({ method: 'POST', url: `/tasks/${taskId}/notes`, data: { content, type } }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
})
}

View File

@@ -1,92 +0,0 @@
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<WorkTask[]>({ url: '/tasks', params: { includeSubTasks } }),
})
}
export function useActiveTask() {
return useQuery({
queryKey: ['tasks', 'active'],
queryFn: () => request<WorkTask | null>({ url: '/tasks/active' }),
refetchInterval: 30_000,
})
}
export function useTask(id: number) {
return useQuery({
queryKey: ['tasks', id],
queryFn: () => request<WorkTask>({ 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<WorkTask>({ 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<WorkTask>({ method: 'PUT', url: `/tasks/${id}`, data: body }),
onSuccess: invalidate,
})
}
export function useStartTask() {
const invalidate = useInvalidateTasks()
return useMutation({
mutationFn: (id: number) => request<WorkTask>({ method: 'PUT', url: `/tasks/${id}/start` }),
onSuccess: invalidate,
})
}
export function usePauseTask() {
const invalidate = useInvalidateTasks()
return useMutation({
mutationFn: ({ id, note }: { id: number; note?: string }) =>
request<WorkTask>({ 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<WorkTask>({ method: 'PUT', url: `/tasks/${id}/resume`, data: { note } }),
onSuccess: invalidate,
})
}
export function useCompleteTask() {
const invalidate = useInvalidateTasks()
return useMutation({
mutationFn: (id: number) => request<WorkTask>({ method: 'PUT', url: `/tasks/${id}/complete` }),
onSuccess: invalidate,
})
}
export function useAbandonTask() {
const invalidate = useInvalidateTasks()
return useMutation({
mutationFn: (id: number) => request<void>({ method: 'DELETE', url: `/tasks/${id}` }),
onSuccess: invalidate,
})
}

View File

@@ -1,125 +0,0 @@
import { useRef, useState, useEffect } from 'react'
import { Loader2 } from 'lucide-react'
import { useCreateTask } from '../api/tasks.ts'
import { CATEGORY_COLORS } from '../lib/constants.ts'
interface CreateTaskFormProps {
onClose: () => void
}
const categories = Object.keys(CATEGORY_COLORS).filter((k) => k !== 'Unknown')
export default function CreateTaskForm({ onClose }: CreateTaskFormProps) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState('')
const [estimatedMinutes, setEstimatedMinutes] = useState('')
const titleRef = useRef<HTMLInputElement>(null)
const createTask = useCreateTask()
useEffect(() => {
titleRef.current?.focus()
}, [])
function handleSubmit() {
const trimmed = title.trim()
if (!trimmed) return
createTask.mutate(
{
title: trimmed,
description: description.trim() || undefined,
category: category || undefined,
estimatedMinutes: estimatedMinutes ? Number(estimatedMinutes) : undefined,
},
{ onSuccess: () => onClose() }
)
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
onClose()
}
if (e.key === 'Enter' && (e.target as HTMLElement).tagName !== 'TEXTAREA') {
e.preventDefault()
handleSubmit()
}
}
const inputClass =
'w-full rounded-md bg-[var(--color-page)] text-white text-sm px-3 py-2 border border-[var(--color-border)] placeholder-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]/60 focus:border-transparent transition-colors'
return (
<div
className="rounded-lg bg-[var(--color-surface)] p-3 space-y-3"
onKeyDown={handleKeyDown}
>
{/* Title */}
<input
ref={titleRef}
type="text"
placeholder="Task title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className={inputClass}
/>
{/* Description */}
<textarea
placeholder="Description (optional)"
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
className={`${inputClass} resize-none`}
/>
{/* Category + Estimated Minutes row */}
<div className="flex gap-2">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className={`${inputClass} flex-1 appearance-none cursor-pointer`}
>
<option value="">Category</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
<input
type="number"
placeholder="Est. min"
min={1}
value={estimatedMinutes}
onChange={(e) => setEstimatedMinutes(e.target.value)}
className={`${inputClass} w-24`}
/>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
className="text-xs text-[var(--color-text-secondary)] hover:text-white transition-colors px-2 py-1"
>
Cancel
</button>
<button
type="button"
onClick={handleSubmit}
disabled={!title.trim() || createTask.isPending}
className="flex items-center gap-1.5 text-xs font-medium text-white bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110
disabled:opacity-40 disabled:cursor-not-allowed
px-3 py-1.5 rounded-md transition-colors"
>
{createTask.isPending && <Loader2 size={12} className="animate-spin" />}
Create
</button>
</div>
</div>
)
}

View File

@@ -1,137 +0,0 @@
import { useMemo } from 'react'
import { X, ListTree } from 'lucide-react'
import type { WorkTask } from '../types/index.ts'
import { CATEGORY_COLORS } from '../lib/constants.ts'
export interface Filters {
categories: string[]
hasSubtasks: boolean
}
export const EMPTY_FILTERS: Filters = { categories: [], hasSubtasks: false }
interface FilterBarProps {
tasks: WorkTask[]
filters: Filters
onFiltersChange: (filters: Filters) => void
}
export function applyFilters(tasks: WorkTask[], filters: Filters): WorkTask[] {
let result = tasks
if (filters.categories.length > 0) {
result = result.filter((t) => filters.categories.includes(t.category ?? 'Unknown'))
}
if (filters.hasSubtasks) {
result = result.filter((t) => t.subTasks && t.subTasks.length > 0)
}
return result
}
export default function FilterBar({ tasks, filters, onFiltersChange }: FilterBarProps) {
// Derive unique categories from tasks + CATEGORY_COLORS keys
const allCategories = useMemo(() => {
const fromTasks = new Set(tasks.map((t) => t.category ?? 'Unknown'))
const fromConfig = new Set(Object.keys(CATEGORY_COLORS))
const merged = new Set([...fromConfig, ...fromTasks])
return Array.from(merged).sort()
}, [tasks])
const hasActiveFilters = filters.categories.length > 0 || filters.hasSubtasks
const toggleCategory = (category: string) => {
const active = filters.categories
const next = active.includes(category)
? active.filter((c) => c !== category)
: [...active, category]
onFiltersChange({ ...filters, categories: next })
}
const toggleHasSubtasks = () => {
onFiltersChange({ ...filters, hasSubtasks: !filters.hasSubtasks })
}
const clearAll = () => {
onFiltersChange(EMPTY_FILTERS)
}
return (
<div className="flex items-center gap-2 flex-wrap mb-4">
{/* "All" chip */}
<button
onClick={clearAll}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium transition-colors ${
!hasActiveFilters
? 'bg-[var(--color-accent)] text-white'
: 'bg-white/[0.06] text-[var(--color-text-secondary)] hover:text-white'
}`}
>
All
</button>
{/* Divider */}
<div className="w-px h-4 bg-white/[0.06]" />
{/* Category chips */}
{allCategories.map((cat) => {
const isActive = filters.categories.includes(cat)
const color = CATEGORY_COLORS[cat] ?? CATEGORY_COLORS['Unknown']
return (
<button
key={cat}
onClick={() => toggleCategory(cat)}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium transition-colors ${
isActive ? 'text-white' : 'bg-white/[0.06] text-[var(--color-text-secondary)] hover:text-white'
}`}
style={
isActive
? { backgroundColor: color, color: '#fff' }
: undefined
}
>
{cat}
{isActive && (
<X
size={10}
className="ml-0.5 opacity-70 hover:opacity-100"
onClick={(e) => {
e.stopPropagation()
toggleCategory(cat)
}}
/>
)}
</button>
)
})}
{/* Divider */}
<div className="w-px h-4 bg-white/[0.06]" />
{/* Has subtasks chip */}
<button
onClick={toggleHasSubtasks}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium transition-colors ${
filters.hasSubtasks
? 'bg-[var(--color-accent)] text-white'
: 'bg-white/[0.06] text-[var(--color-text-secondary)] hover:text-white'
}`}
>
<ListTree size={12} />
Has subtasks
{filters.hasSubtasks && (
<X
size={10}
className="ml-0.5 opacity-70 hover:opacity-100"
onClick={(e) => {
e.stopPropagation()
toggleHasSubtasks()
}}
/>
)}
</button>
</div>
)
}

View File

@@ -1,153 +0,0 @@
import { useMemo, useCallback } from 'react'
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
closestCorners,
} from '@dnd-kit/core'
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import {
useStartTask,
usePauseTask,
useResumeTask,
useCompleteTask,
} from '../api/tasks.ts'
import { WorkTaskStatus } from '../types/index.ts'
import type { WorkTask } from '../types/index.ts'
import { COLUMN_CONFIG } from '../lib/constants.ts'
import KanbanColumn from './KanbanColumn.tsx'
import TaskCard from './TaskCard.tsx'
interface KanbanBoardProps {
tasks: WorkTask[]
isLoading: boolean
onTaskClick: (id: number) => void
}
export default function KanbanBoard({ tasks, isLoading, onTaskClick }: KanbanBoardProps) {
const startTask = useStartTask()
const pauseTask = usePauseTask()
const resumeTask = useResumeTask()
const completeTask = useCompleteTask()
const [activeTask, setActiveTask] = useState<WorkTask | null>(null)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
)
// Filter to top-level tasks only and group by status
const columns = useMemo(() => {
const topLevel = tasks.filter((t) => t.parentTaskId === null)
return COLUMN_CONFIG.map((col) => ({
...col,
tasks: topLevel.filter((t) => t.status === col.status),
}))
}, [tasks])
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const draggedId = Number(event.active.id)
const task = tasks.find((t) => t.id === draggedId) ?? null
setActiveTask(task)
},
[tasks]
)
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveTask(null)
const { active, over } = event
if (!over) return
const taskId = Number(active.id)
const task = tasks.find((t) => t.id === taskId)
if (!task) return
// Determine target status from the droppable column ID
let targetStatus: string | null = null
const overId = String(over.id)
if (overId.startsWith('column-')) {
targetStatus = overId.replace('column-', '')
} else {
// Dropped over another card - find which column it belongs to
const overTaskId = Number(over.id)
const overTask = tasks.find((t) => t.id === overTaskId)
if (overTask) {
targetStatus = overTask.status
}
}
if (targetStatus === null || targetStatus === task.status) return
// Map transitions to API calls
switch (targetStatus) {
case WorkTaskStatus.Active:
// Works for both Pending->Active and Paused->Active
if (task.status === WorkTaskStatus.Paused) {
resumeTask.mutate({ id: taskId })
} else {
startTask.mutate(taskId)
}
break
case WorkTaskStatus.Paused:
if (task.status === WorkTaskStatus.Active) {
pauseTask.mutate({ id: taskId })
}
break
case WorkTaskStatus.Completed:
completeTask.mutate(taskId)
break
case WorkTaskStatus.Pending:
// Transition back to Pending is not supported
break
}
},
[tasks, startTask, pauseTask, resumeTask, completeTask]
)
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="animate-spin text-[#64748b]" size={32} />
</div>
)
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="grid grid-cols-4 gap-4 h-full">
{columns.map((col) => (
<KanbanColumn
key={col.status}
status={col.status}
label={col.label}
color={col.color}
tasks={col.tasks}
onTaskClick={onTaskClick}
onAddTask={col.status === WorkTaskStatus.Pending ? () => {} : undefined}
/>
))}
</div>
<DragOverlay>
{activeTask ? (
<div className="rotate-1 scale-[1.03] opacity-90">
<TaskCard task={activeTask} onClick={() => {}} />
</div>
) : null}
</DragOverlay>
</DndContext>
)
}

View File

@@ -1,87 +0,0 @@
import { useState } from 'react'
import { useDroppable } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { Plus } from 'lucide-react'
import { WorkTaskStatus, type WorkTask } from '../types/index.ts'
import TaskCard from './TaskCard.tsx'
import CreateTaskForm from './CreateTaskForm.tsx'
interface KanbanColumnProps {
status: string
label: string
color: string
tasks: WorkTask[]
onTaskClick: (id: number) => void
onAddTask?: () => void
}
export default function KanbanColumn({
status,
label,
color,
tasks,
onTaskClick,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id: `column-${status}` })
const [showForm, setShowForm] = useState(false)
const taskIds = tasks.map((t) => t.id)
return (
<div className="flex flex-col min-h-[300px]">
{/* Column header */}
<div className="mb-3">
<div className="flex items-center gap-2 mb-2">
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
{label}
</h2>
<span className="text-[11px] text-[var(--color-text-tertiary)]">
{tasks.length}
</span>
</div>
<div className="h-[2px] rounded-full" style={{ backgroundColor: color }} />
</div>
{/* Cards area */}
<div
ref={setNodeRef}
className={`flex-1 flex flex-col gap-2 rounded-lg transition-colors duration-200 py-1 ${
isOver ? 'bg-white/[0.02]' : ''
}`}
>
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
{tasks.map((task) => (
<TaskCard key={task.id} task={task} onClick={onTaskClick} />
))}
</SortableContext>
{/* Empty state */}
{tasks.length === 0 && !showForm && (
<div className="flex-1 flex items-center justify-center min-h-[80px] rounded-lg border border-dashed border-white/[0.06]">
<span className="text-[11px] text-[var(--color-text-tertiary)]">No tasks</span>
</div>
)}
</div>
{/* Add task (Pending column only) */}
{status === WorkTaskStatus.Pending && (
<div className="mt-2">
{showForm ? (
<CreateTaskForm onClose={() => setShowForm(false)} />
) : (
<button
onClick={() => setShowForm(true)}
className="flex items-center gap-1.5 w-full py-2 rounded-lg
text-[11px] text-[var(--color-text-tertiary)]
hover:text-[var(--color-text-secondary)] hover:bg-white/[0.02]
transition-colors"
>
<Plus size={13} />
New task
</button>
)}
</div>
)}
</div>
)
}

View File

@@ -1,108 +0,0 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { LayoutGrid, BarChart3, Link, Plus, Search } from 'lucide-react'
import { useState, useEffect, useCallback } from 'react'
import SearchModal from './SearchModal.tsx'
const navItems = [
{ to: '/board', label: 'Board', icon: LayoutGrid },
{ to: '/analytics', label: 'Analytics', icon: BarChart3 },
{ to: '/mappings', label: 'Mappings', icon: Link },
]
export default function Layout() {
const navigate = useNavigate()
const [searchOpen, setSearchOpen] = useState(false)
const [showCreateHint, setShowCreateHint] = useState(false)
// Global Cmd+K handler
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
setSearchOpen(true)
}
}, [])
useEffect(() => {
document.addEventListener('keydown', handleGlobalKeyDown)
return () => document.removeEventListener('keydown', handleGlobalKeyDown)
}, [handleGlobalKeyDown])
return (
<div className="flex flex-col h-screen bg-[var(--color-page)] text-[var(--color-text-primary)] overflow-hidden">
{/* Top navigation bar */}
<header className="flex items-center h-12 px-4 border-b border-[var(--color-border)] shrink-0 bg-[var(--color-page)]">
{/* Logo */}
<div className="flex items-center gap-2 mr-8">
<div className="w-5 h-5 rounded bg-gradient-to-br from-[var(--color-accent)] to-[var(--color-accent-end)] flex items-center justify-center">
<span className="text-[10px] font-bold text-white">T</span>
</div>
<span className="text-sm font-semibold tracking-tight">TaskTracker</span>
</div>
{/* Nav tabs */}
<nav className="flex items-center gap-1">
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[13px] font-medium transition-colors ${
isActive
? 'text-white bg-white/[0.08]'
: 'text-[var(--color-text-secondary)] hover:text-white hover:bg-white/[0.04]'
}`
}
>
<Icon size={15} />
{label}
</NavLink>
))}
</nav>
{/* Spacer */}
<div className="flex-1" />
{/* Search trigger */}
<button
onClick={() => setSearchOpen(true)}
className="flex items-center gap-2 h-7 px-2.5 rounded-md text-[12px] text-[var(--color-text-secondary)] bg-white/[0.04] border border-[var(--color-border)] hover:border-[var(--color-border-hover)] hover:text-[var(--color-text-primary)] transition-colors mr-2"
>
<Search size={13} />
<span className="hidden sm:inline">Search</span>
<kbd className="hidden sm:inline text-[10px] font-mono text-[var(--color-text-tertiary)] bg-white/[0.06] px-1 py-0.5 rounded">
Ctrl K
</kbd>
</button>
{/* New task button */}
<button
onClick={() => {
navigate('/board')
setShowCreateHint(true)
setTimeout(() => setShowCreateHint(false), 100)
}}
className="flex items-center gap-1 h-7 px-2.5 rounded-md text-[12px] font-medium text-white bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 transition-all"
>
<Plus size={14} />
<span className="hidden sm:inline">New Task</span>
</button>
</header>
{/* Content */}
<main className="flex-1 overflow-auto p-5">
<Outlet context={{ showCreateHint }} />
</main>
{/* Search modal */}
{searchOpen && (
<SearchModal
onSelect={(taskId) => {
setSearchOpen(false)
navigate(`/board?task=${taskId}`)
}}
onClose={() => setSearchOpen(false)}
/>
)}
</div>
)
}

View File

@@ -1,126 +0,0 @@
import { useState, useRef, useEffect } from 'react'
import { Plus } from 'lucide-react'
import { NoteType } from '../types/index.ts'
import type { TaskNote } from '../types/index.ts'
import { useCreateNote } from '../api/notes.ts'
interface NotesListProps {
taskId: number
notes: TaskNote[]
}
const NOTE_TYPE_CONFIG: Record<string, { label: string; bg: string; text: string }> = {
[NoteType.PauseNote]: { label: 'Pause', bg: 'bg-amber-500/10', text: 'text-amber-400' },
[NoteType.ResumeNote]: { label: 'Resume', bg: 'bg-blue-500/10', text: 'text-blue-400' },
[NoteType.General]: { label: 'General', bg: 'bg-white/5', text: 'text-[var(--color-text-secondary)]' },
}
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr)
const now = Date.now()
const diffMs = now - date.getTime()
const diffMins = Math.floor(diffMs / 60_000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
if (diffDays === 1) return 'yesterday'
if (diffDays < 7) return `${diffDays}d ago`
const diffWeeks = Math.floor(diffDays / 7)
if (diffWeeks < 5) return `${diffWeeks}w ago`
return date.toLocaleDateString()
}
export default function NotesList({ taskId, notes }: NotesListProps) {
const [showInput, setShowInput] = useState(false)
const [inputValue, setInputValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const createNote = useCreateNote()
useEffect(() => {
if (showInput) inputRef.current?.focus()
}, [showInput])
// Chronological order (oldest first)
const sortedNotes = [...notes].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
)
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter' && inputValue.trim()) {
createNote.mutate(
{ taskId, content: inputValue.trim(), type: NoteType.General },
{
onSuccess: () => {
setInputValue('')
},
}
)
}
if (e.key === 'Escape') {
setShowInput(false)
setInputValue('')
}
}
return (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
Notes
</h3>
<button
onClick={() => setShowInput(true)}
className="p-1 rounded hover:bg-white/5 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
>
<Plus size={14} />
</button>
</div>
<div className="space-y-3">
{sortedNotes.map((note) => {
const typeConfig = NOTE_TYPE_CONFIG[note.type] ?? NOTE_TYPE_CONFIG[NoteType.General]
return (
<div key={note.id} className="text-sm">
<div className="flex items-center gap-2 mb-1">
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${typeConfig.bg} ${typeConfig.text}`}
>
{typeConfig.label}
</span>
<span className="text-[11px] text-[var(--color-text-tertiary)]">
{formatRelativeTime(note.createdAt)}
</span>
</div>
<p className="text-[var(--color-text-primary)] leading-relaxed">{note.content}</p>
</div>
)
})}
{sortedNotes.length === 0 && !showInput && (
<p className="text-sm text-[var(--color-text-secondary)] italic">No notes yet</p>
)}
{showInput && (
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => {
if (!inputValue.trim()) {
setShowInput(false)
setInputValue('')
}
}}
placeholder="Add a note..."
className="w-full bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-3 py-2 rounded border border-transparent focus:border-[var(--color-accent)] outline-none placeholder-[var(--color-text-secondary)]"
/>
)}
</div>
</div>
)
}

View File

@@ -1,192 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { Search, ArrowRight } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { useTasks } from '../api/tasks.ts'
import { CATEGORY_COLORS, COLUMN_CONFIG } from '../lib/constants.ts'
import type { WorkTask } from '../types/index.ts'
interface SearchModalProps {
onSelect: (taskId: number) => void
onClose: () => void
}
export default function SearchModal({ onSelect, onClose }: SearchModalProps) {
const { data: tasks } = useTasks()
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
}, [])
// Filter tasks
const results: WorkTask[] = (() => {
if (!tasks) return []
if (!query.trim()) {
// Show recent/active tasks when no query
return tasks
.filter((t) => t.status === 'Active' || t.status === 'Paused' || t.status === 'Pending')
.slice(0, 8)
}
const q = query.toLowerCase()
return tasks
.filter(
(t) =>
t.title.toLowerCase().includes(q) ||
(t.description && t.description.toLowerCase().includes(q)) ||
(t.category && t.category.toLowerCase().includes(q))
)
.slice(0, 8)
})()
useEffect(() => {
setSelectedIndex(0)
}, [query])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex((prev) => Math.max(prev - 1, 0))
break
case 'Enter':
e.preventDefault()
if (results[selectedIndex]) {
onSelect(results[selectedIndex].id)
}
break
case 'Escape':
e.preventDefault()
onClose()
break
}
},
[results, selectedIndex, onSelect, onClose]
)
const getStatusColor = (status: string) => {
const col = COLUMN_CONFIG.find((c) => c.status === status)
return col ? col.color : '#64748b'
}
return (
<AnimatePresence>
<motion.div
className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<motion.div
className="relative w-full max-w-lg bg-[var(--color-elevated)] border border-[var(--color-border)] rounded-xl shadow-2xl shadow-black/50 overflow-hidden"
initial={{ opacity: 0, scale: 0.95, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -10 }}
transition={{ duration: 0.15 }}
>
{/* Search input */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--color-border)]">
<Search size={16} className="text-[var(--color-text-secondary)] shrink-0" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search tasks..."
className="flex-1 bg-transparent text-[var(--color-text-primary)] text-sm placeholder-[var(--color-text-tertiary)] outline-none"
/>
<kbd className="text-[10px] font-mono text-[var(--color-text-tertiary)] bg-white/[0.06] px-1.5 py-0.5 rounded border border-[var(--color-border)]">
ESC
</kbd>
</div>
{/* Results */}
{results.length > 0 ? (
<div className="max-h-[300px] overflow-y-auto py-1">
{!query.trim() && (
<div className="px-4 py-1.5 text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">
Recent tasks
</div>
)}
{results.map((task, index) => {
const categoryColor = CATEGORY_COLORS[task.category ?? 'Unknown'] ?? CATEGORY_COLORS['Unknown']
const statusColor = getStatusColor(task.status)
return (
<button
key={task.id}
onClick={() => onSelect(task.id)}
onMouseEnter={() => setSelectedIndex(index)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
index === selectedIndex ? 'bg-white/[0.06]' : ''
}`}
>
{/* Status dot */}
<span
className="shrink-0 w-2 h-2 rounded-full"
style={{ backgroundColor: statusColor }}
/>
{/* Title */}
<span className="flex-1 text-sm text-[var(--color-text-primary)] truncate">
{task.title}
</span>
{/* Category */}
{task.category && (
<span
className="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded"
style={{
backgroundColor: categoryColor + '15',
color: categoryColor,
}}
>
{task.category}
</span>
)}
{/* Arrow hint on selected */}
{index === selectedIndex && (
<ArrowRight size={12} className="shrink-0 text-[var(--color-text-tertiary)]" />
)}
</button>
)
})}
</div>
) : query.trim() ? (
<div className="px-4 py-8 text-center text-sm text-[var(--color-text-secondary)]">
No tasks found
</div>
) : null}
{/* Footer */}
<div className="flex items-center gap-4 px-4 py-2 border-t border-[var(--color-border)] text-[10px] text-[var(--color-text-tertiary)]">
<span className="flex items-center gap-1">
<kbd className="font-mono bg-white/[0.06] px-1 py-0.5 rounded">&uarr;&darr;</kbd>
Navigate
</span>
<span className="flex items-center gap-1">
<kbd className="font-mono bg-white/[0.06] px-1 py-0.5 rounded">&crarr;</kbd>
Open
</span>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}

View File

@@ -1,108 +0,0 @@
import { useState, useRef, useEffect } from 'react'
import { Plus, Square, CheckSquare } from 'lucide-react'
import { WorkTaskStatus } from '../types/index.ts'
import type { WorkTask } from '../types/index.ts'
import { useCreateTask, useCompleteTask } from '../api/tasks.ts'
interface SubtaskListProps {
taskId: number
subtasks: WorkTask[]
}
export default function SubtaskList({ taskId, subtasks }: SubtaskListProps) {
const [showInput, setShowInput] = useState(false)
const [inputValue, setInputValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const createTask = useCreateTask()
const completeTask = useCompleteTask()
useEffect(() => {
if (showInput) inputRef.current?.focus()
}, [showInput])
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter' && inputValue.trim()) {
createTask.mutate(
{ title: inputValue.trim(), parentTaskId: taskId },
{
onSuccess: () => {
setInputValue('')
},
}
)
}
if (e.key === 'Escape') {
setShowInput(false)
setInputValue('')
}
}
function handleToggle(subtask: WorkTask) {
if (subtask.status !== WorkTaskStatus.Completed) {
completeTask.mutate(subtask.id)
}
}
return (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider">
Subtasks
</h3>
<button
onClick={() => setShowInput(true)}
className="p-1 rounded hover:bg-white/5 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors"
>
<Plus size={14} />
</button>
</div>
<div className="space-y-1">
{subtasks.map((subtask) => {
const isCompleted = subtask.status === WorkTaskStatus.Completed
return (
<div
key={subtask.id}
className="flex items-center gap-2 py-1.5 px-1 rounded hover:bg-white/5 cursor-pointer group"
onClick={() => handleToggle(subtask)}
>
{isCompleted ? (
<CheckSquare size={16} className="text-[var(--color-status-completed)] flex-shrink-0" />
) : (
<Square size={16} className="text-[var(--color-text-secondary)] group-hover:text-[var(--color-text-primary)] flex-shrink-0" />
)}
<span
className={`text-sm ${
isCompleted ? 'line-through text-[var(--color-text-secondary)]' : 'text-[var(--color-text-primary)]'
}`}
>
{subtask.title}
</span>
</div>
)
})}
{showInput && (
<div className="flex items-center gap-2 py-1.5 px-1">
<Square size={16} className="text-[var(--color-text-secondary)] flex-shrink-0" />
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => {
if (!inputValue.trim()) {
setShowInput(false)
setInputValue('')
}
}}
placeholder="New subtask..."
className="flex-1 bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-2 py-1 rounded border border-transparent focus:border-[var(--color-accent)] outline-none placeholder-[var(--color-text-secondary)]"
/>
</div>
)}
</div>
</div>
)
}

View File

@@ -1,118 +0,0 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Clock } from 'lucide-react'
import { WorkTaskStatus } from '../types/index.ts'
import type { WorkTask } from '../types/index.ts'
import { CATEGORY_COLORS } from '../lib/constants.ts'
function formatElapsed(task: WorkTask): string | null {
if (!task.startedAt) return null
const start = new Date(task.startedAt).getTime()
const end = task.completedAt ? new Date(task.completedAt).getTime() : Date.now()
const mins = Math.floor((end - start) / 60_000)
if (mins < 60) return `${mins}m`
const hours = Math.floor(mins / 60)
const remainder = mins % 60
if (hours < 24) return `${hours}h ${remainder}m`
const days = Math.floor(hours / 24)
return `${days}d ${hours % 24}h`
}
interface TaskCardProps {
task: WorkTask
onClick: (id: number) => void
}
export default function TaskCard({ task, onClick }: TaskCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: task.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
}
const categoryColor = CATEGORY_COLORS[task.category ?? 'Unknown'] ?? CATEGORY_COLORS['Unknown']
const isActive = task.status === WorkTaskStatus.Active
const elapsed = formatElapsed(task)
const completedSubTasks = task.subTasks?.filter(
(s) => s.status === WorkTaskStatus.Completed
).length ?? 0
const totalSubTasks = task.subTasks?.length ?? 0
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={() => onClick(task.id)}
className={`
card-glow rounded-xl cursor-grab active:cursor-grabbing
bg-[var(--color-surface)] border transition-all duration-200
hover:-translate-y-0.5
${isActive
? 'border-[var(--color-status-active)]/30 animate-pulse-glow'
: 'border-[var(--color-border)] hover:border-[var(--color-border-hover)]'
}
${isDragging ? 'shadow-xl shadow-black/40' : ''}
`}
>
<div className="px-3.5 py-3">
{/* Title row */}
<div className="flex items-start gap-2 mb-1.5">
{isActive && (
<span className="shrink-0 mt-1.5 w-1.5 h-1.5 rounded-full bg-[var(--color-status-active)] animate-live-dot" />
)}
<p className="text-[13px] font-medium text-[var(--color-text-primary)] leading-snug flex-1">
{task.title}
</p>
</div>
{/* Meta row */}
<div className="flex items-center gap-2 text-[11px] text-[var(--color-text-secondary)]">
{task.category && (
<span className="flex items-center gap-1">
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: categoryColor }}
/>
{task.category}
</span>
)}
{elapsed && (
<span className="flex items-center gap-1">
<Clock size={10} />
{elapsed}
</span>
)}
{totalSubTasks > 0 && (
<span className="ml-auto flex items-center gap-1">
{Array.from({ length: totalSubTasks }, (_, i) => (
<span
key={i}
className={`w-1 h-1 rounded-full ${
i < completedSubTasks
? 'bg-[var(--color-status-completed)]'
: 'bg-white/10'
}`}
/>
))}
<span className="ml-0.5">{completedSubTasks}/{totalSubTasks}</span>
</span>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,441 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { motion } from 'framer-motion'
import { X, Loader2 } from 'lucide-react'
import { WorkTaskStatus } from '../types/index.ts'
import {
useTask,
useUpdateTask,
useStartTask,
usePauseTask,
useResumeTask,
useCompleteTask,
useAbandonTask,
} from '../api/tasks.ts'
import { COLUMN_CONFIG } from '../lib/constants.ts'
import SubtaskList from './SubtaskList.tsx'
import NotesList from './NotesList.tsx'
interface TaskDetailPanelProps {
taskId: number
onClose: () => void
}
function formatElapsed(startedAt: string, completedAt: string | null): string {
const start = new Date(startedAt).getTime()
const end = completedAt ? new Date(completedAt).getTime() : Date.now()
const mins = Math.floor((end - start) / 60_000)
if (mins < 60) return `${mins}m`
const hours = Math.floor(mins / 60)
const remainder = mins % 60
if (hours < 24) return `${hours}h ${remainder}m`
const days = Math.floor(hours / 24)
return `${days}d ${hours % 24}h`
}
export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProps) {
const { data: task, isLoading } = useTask(taskId)
const updateTask = useUpdateTask()
const startTask = useStartTask()
const pauseTask = usePauseTask()
const resumeTask = useResumeTask()
const completeTask = useCompleteTask()
const abandonTask = useAbandonTask()
// Inline editing states
const [editingTitle, setEditingTitle] = useState(false)
const [titleValue, setTitleValue] = useState('')
const [editingDesc, setEditingDesc] = useState(false)
const [descValue, setDescValue] = useState('')
const [editingCategory, setEditingCategory] = useState(false)
const [categoryValue, setCategoryValue] = useState('')
const [editingEstimate, setEditingEstimate] = useState(false)
const [estimateValue, setEstimateValue] = useState('')
const titleInputRef = useRef<HTMLInputElement>(null)
const descInputRef = useRef<HTMLTextAreaElement>(null)
const categoryInputRef = useRef<HTMLInputElement>(null)
const estimateInputRef = useRef<HTMLInputElement>(null)
// Escape key handler
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
// If editing, cancel editing first
if (editingTitle || editingDesc || editingCategory || editingEstimate) {
setEditingTitle(false)
setEditingDesc(false)
setEditingCategory(false)
setEditingEstimate(false)
return
}
onClose()
}
},
[editingTitle, editingDesc, editingCategory, editingEstimate, onClose]
)
useEffect(() => {
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [handleEscape])
// Focus inputs when entering edit mode
useEffect(() => {
if (editingTitle) titleInputRef.current?.focus()
}, [editingTitle])
useEffect(() => {
if (editingDesc) descInputRef.current?.focus()
}, [editingDesc])
useEffect(() => {
if (editingCategory) categoryInputRef.current?.focus()
}, [editingCategory])
useEffect(() => {
if (editingEstimate) estimateInputRef.current?.focus()
}, [editingEstimate])
// --- Save handlers ---
function saveTitle() {
if (task && titleValue.trim() && titleValue.trim() !== task.title) {
updateTask.mutate({ id: taskId, title: titleValue.trim() })
}
setEditingTitle(false)
}
function saveDescription() {
if (task && descValue !== (task.description ?? '')) {
updateTask.mutate({ id: taskId, description: descValue })
}
setEditingDesc(false)
}
function saveCategory() {
if (task && categoryValue.trim() !== (task.category ?? '')) {
updateTask.mutate({ id: taskId, category: categoryValue.trim() || undefined })
}
setEditingCategory(false)
}
function saveEstimate() {
const val = estimateValue.trim() === '' ? undefined : parseInt(estimateValue, 10)
if (task) {
const newVal = val && !isNaN(val) ? val : undefined
if (newVal !== (task.estimatedMinutes ?? undefined)) {
updateTask.mutate({ id: taskId, estimatedMinutes: newVal })
}
}
setEditingEstimate(false)
}
// --- Status helpers ---
const statusConfig = COLUMN_CONFIG.find((c) => c.status === task?.status)
// Progress percent
let progressPercent: number | null = null
if (task?.estimatedMinutes && task.startedAt) {
const start = new Date(task.startedAt).getTime()
const end = task.completedAt ? new Date(task.completedAt).getTime() : Date.now()
const elapsedMins = (end - start) / 60_000
progressPercent = Math.min(100, (elapsedMins / task.estimatedMinutes) * 100)
}
return (
<>
{/* Overlay */}
<motion.div
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* Panel */}
<motion.div
className="fixed top-0 right-0 h-full w-[480px] z-50 bg-[var(--color-elevated)]/95 backdrop-blur-xl border-l border-[var(--color-border)] shadow-2xl flex flex-col"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
>
{isLoading || !task ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="animate-spin text-[var(--color-text-secondary)]" size={32} />
</div>
) : (
<>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto">
{/* Header */}
<div className="p-5 pb-4">
{/* Title row with close button inline */}
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
{editingTitle ? (
<input
ref={titleInputRef}
type="text"
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
onBlur={saveTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') saveTitle()
if (e.key === 'Escape') setEditingTitle(false)
}}
className="w-full bg-[var(--color-page)] text-xl font-semibold text-[var(--color-text-primary)] px-3 py-2 rounded border border-[var(--color-accent)] outline-none"
/>
) : (
<h2
className="text-xl font-semibold text-[var(--color-text-primary)] cursor-pointer hover:text-[var(--color-accent)] transition-colors"
onClick={() => {
setTitleValue(task.title)
setEditingTitle(true)
}}
>
{task.title}
</h2>
)}
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-white/10 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors shrink-0 mt-0.5"
>
<X size={18} />
</button>
</div>
{/* Status badge + Category */}
<div className="flex items-center gap-2 mt-3">
{statusConfig && (
<span
className="text-[10px] px-2.5 py-1 rounded-full"
style={{
backgroundColor: statusConfig.color + '20',
color: statusConfig.color,
}}
>
{statusConfig.label}
</span>
)}
{editingCategory ? (
<input
ref={categoryInputRef}
type="text"
value={categoryValue}
onChange={(e) => setCategoryValue(e.target.value)}
onBlur={saveCategory}
onKeyDown={(e) => {
if (e.key === 'Enter') saveCategory()
if (e.key === 'Escape') setEditingCategory(false)
}}
placeholder="Category..."
className="bg-[var(--color-page)] text-xs text-[var(--color-text-primary)] px-2 py-1 rounded border border-[var(--color-accent)] outline-none w-28"
/>
) : (
<span
className="text-[11px] text-[var(--color-text-secondary)] cursor-pointer hover:text-[var(--color-text-primary)] transition-colors px-2.5 py-1 rounded-full bg-white/5"
onClick={() => {
setCategoryValue(task.category ?? '')
setEditingCategory(true)
}}
>
{task.category || 'Add category'}
</span>
)}
</div>
</div>
<div className="border-t border-[var(--color-border)]" />
{/* Description */}
<div className="p-5">
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] mb-2">
Description
</h3>
{editingDesc ? (
<textarea
ref={descInputRef}
value={descValue}
onChange={(e) => setDescValue(e.target.value)}
onBlur={saveDescription}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setEditingDesc(false)
e.stopPropagation()
}
}}
rows={4}
className="w-full bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-3 py-2 rounded border border-[var(--color-accent)] outline-none resize-none"
placeholder="Add a description..."
/>
) : (
<p
className={`text-sm cursor-pointer rounded px-3 py-2 hover:bg-white/5 transition-colors ${
task.description ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)] italic'
}`}
onClick={() => {
setDescValue(task.description ?? '')
setEditingDesc(true)
}}
>
{task.description || 'Add a description...'}
</p>
)}
</div>
<div className="border-t border-[var(--color-border)]" />
{/* Time */}
<div className="p-5">
<h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] mb-3">
Time
</h3>
<div className="grid grid-cols-2 gap-4 mb-3">
<div>
<span className="text-[11px] text-[var(--color-text-secondary)] block mb-1">Elapsed</span>
<span className="text-sm text-[var(--color-text-primary)] font-medium">
{task.startedAt ? formatElapsed(task.startedAt, task.completedAt) : '--'}
</span>
</div>
<div>
<span className="text-[11px] text-[var(--color-text-secondary)] block mb-1">Estimate</span>
{editingEstimate ? (
<input
ref={estimateInputRef}
type="number"
value={estimateValue}
onChange={(e) => setEstimateValue(e.target.value)}
onBlur={saveEstimate}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEstimate()
if (e.key === 'Escape') setEditingEstimate(false)
}}
placeholder="min"
className="w-full bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-2 py-1 rounded border border-[var(--color-accent)] outline-none"
/>
) : (
<span
className="text-sm text-[var(--color-text-primary)] font-medium cursor-pointer hover:text-[var(--color-accent)] transition-colors"
onClick={() => {
setEstimateValue(task.estimatedMinutes?.toString() ?? '')
setEditingEstimate(true)
}}
>
{task.estimatedMinutes ? `${task.estimatedMinutes}m` : '--'}
</span>
)}
</div>
</div>
{/* Progress bar */}
{progressPercent !== null && (
<div className="h-2 w-full bg-white/5 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${
progressPercent >= 100
? 'bg-rose-500'
: 'bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)]'
}`}
style={{ width: `${Math.min(progressPercent, 100)}%` }}
/>
</div>
)}
</div>
<div className="border-t border-[var(--color-border)]" />
{/* Subtasks */}
<div className="p-5">
<SubtaskList taskId={taskId} subtasks={task.subTasks ?? []} />
</div>
<div className="border-t border-[var(--color-border)]" />
{/* Notes */}
<div className="p-5">
<NotesList taskId={taskId} notes={task.notes ?? []} />
</div>
</div>
{/* Action buttons - fixed at bottom */}
{task.status !== WorkTaskStatus.Completed && task.status !== WorkTaskStatus.Abandoned && (
<div className="border-t border-[var(--color-border)] p-5 space-y-2">
{task.status === WorkTaskStatus.Pending && (
<>
<button
onClick={() => startTask.mutate(taskId)}
disabled={startTask.isPending}
className="w-full py-2.5 rounded-lg bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 text-white text-sm font-medium transition-all disabled:opacity-50"
>
Start
</button>
<button
onClick={() => abandonTask.mutate(taskId)}
disabled={abandonTask.isPending}
className="w-full py-2.5 rounded-lg bg-transparent border border-rose-500/30 hover:bg-rose-500/10 text-rose-400 text-sm font-medium transition-colors disabled:opacity-50"
>
Abandon
</button>
</>
)}
{task.status === WorkTaskStatus.Active && (
<>
<button
onClick={() => pauseTask.mutate({ id: taskId })}
disabled={pauseTask.isPending}
className="w-full py-2.5 rounded-lg bg-amber-600 hover:bg-amber-500 text-white text-sm font-medium transition-colors disabled:opacity-50"
>
Pause
</button>
<button
onClick={() => completeTask.mutate(taskId)}
disabled={completeTask.isPending}
className="w-full py-2.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium transition-colors disabled:opacity-50"
>
Complete
</button>
<button
onClick={() => abandonTask.mutate(taskId)}
disabled={abandonTask.isPending}
className="w-full py-2.5 rounded-lg bg-transparent border border-rose-500/30 hover:bg-rose-500/10 text-rose-400 text-sm font-medium transition-colors disabled:opacity-50"
>
Abandon
</button>
</>
)}
{task.status === WorkTaskStatus.Paused && (
<>
<button
onClick={() => resumeTask.mutate({ id: taskId })}
disabled={resumeTask.isPending}
className="w-full py-2.5 rounded-lg bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 text-white text-sm font-medium transition-all disabled:opacity-50"
>
Resume
</button>
<button
onClick={() => completeTask.mutate(taskId)}
disabled={completeTask.isPending}
className="w-full py-2.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium transition-colors disabled:opacity-50"
>
Complete
</button>
<button
onClick={() => abandonTask.mutate(taskId)}
disabled={abandonTask.isPending}
className="w-full py-2.5 rounded-lg bg-transparent border border-rose-500/30 hover:bg-rose-500/10 text-rose-400 text-sm font-medium transition-colors disabled:opacity-50"
>
Abandon
</button>
</>
)}
</div>
)}
</>
)}
</motion.div>
</>
)
}

View File

@@ -1,140 +0,0 @@
import { useMemo, useState } from 'react'
import { useRecentContext } from '../../api/context'
import { useMappings } from '../../api/mappings'
import { CATEGORY_COLORS } from '../../lib/constants'
interface ActivityFeedProps {
minutes: number
taskId?: number
}
const PAGE_SIZE = 20
function resolveCategory(
appName: string,
mappings: { pattern: string; matchType: string; category: string }[],
): string {
for (const m of mappings) {
if (m.matchType === 'Exact' && m.pattern.toLowerCase() === appName.toLowerCase()) {
return m.category
}
if (m.matchType === 'Contains' && appName.toLowerCase().includes(m.pattern.toLowerCase())) {
return m.category
}
if (m.matchType === 'Regex') {
try {
if (new RegExp(m.pattern, 'i').test(appName)) return m.category
} catch {
// skip invalid regex
}
}
}
return 'Unknown'
}
function formatTimestamp(ts: string): string {
const date = new Date(ts)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60_000)
if (diffMin < 1) return 'just now'
if (diffMin < 60) return `${diffMin}m ago`
// Show time for older events
const h = date.getHours()
const m = date.getMinutes()
const ampm = h >= 12 ? 'pm' : 'am'
const hour12 = h % 12 || 12
const mins = m.toString().padStart(2, '0')
return `${hour12}:${mins}${ampm}`
}
export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
const { data: events, isLoading: eventsLoading } = useRecentContext(minutes)
const { data: mappings, isLoading: mappingsLoading } = useMappings()
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE)
const sortedEvents = useMemo(() => {
if (!events) return []
let filtered = events
if (taskId) {
filtered = events.filter((e) => e.workTaskId === taskId)
}
// Reverse chronological
return [...filtered].sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
)
}, [events, taskId])
const visibleEvents = sortedEvents.slice(0, visibleCount)
const hasMore = visibleCount < sortedEvents.length
if (eventsLoading || mappingsLoading) {
return (
<div className="flex items-center justify-center h-32 text-[var(--color-text-secondary)] text-sm">
Loading activity...
</div>
)
}
if (sortedEvents.length === 0) {
return (
<div className="flex items-center justify-center h-32 text-[var(--color-text-secondary)] text-sm">
No activity events for this time range.
</div>
)
}
return (
<div>
<div className="relative">
{visibleEvents.map((evt, idx) => {
const category = mappings ? resolveCategory(evt.appName, mappings) : 'Unknown'
const color = CATEGORY_COLORS[category] ?? CATEGORY_COLORS['Unknown']
const detail = evt.url || evt.windowTitle || ''
const isLast = idx === visibleEvents.length - 1
return (
<div key={evt.id} className="flex items-start gap-3 relative">
{/* Timeline connector + dot */}
<div className="flex flex-col items-center shrink-0">
<span
className="w-2 h-2 rounded-full mt-1.5 shrink-0 relative z-10"
style={{ backgroundColor: color }}
/>
{!isLast && (
<div className="w-px flex-1 bg-[var(--color-border)]" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0 pb-3">
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--color-text-secondary)] shrink-0">
{formatTimestamp(evt.timestamp)}
</span>
<span className="text-sm text-[var(--color-text-primary)] font-medium truncate">{evt.appName}</span>
</div>
{detail && (
<p className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">{detail}</p>
)}
</div>
</div>
)
})}
</div>
{hasMore && (
<button
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
className="mt-3 w-full py-2 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
>
Load more ({sortedEvents.length - visibleCount} remaining)
</button>
)}
</div>
)
}

View File

@@ -1,143 +0,0 @@
import { useMemo } from 'react'
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'
import { useContextSummary } from '../../api/context'
import { CATEGORY_COLORS } from '../../lib/constants'
interface CategoryBreakdownProps {
minutes: number
taskId?: number
}
interface CategoryData {
name: string
count: number
color: string
percentage: number
}
export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }: CategoryBreakdownProps) {
const { data: summary, isLoading } = useContextSummary()
const categories = useMemo(() => {
if (!summary) return []
// Aggregate by category
const catMap = new Map<string, number>()
for (const item of summary) {
const cat = item.category || 'Unknown'
catMap.set(cat, (catMap.get(cat) ?? 0) + item.eventCount)
}
const total = Array.from(catMap.values()).reduce((s, c) => s + c, 0)
const result: CategoryData[] = Array.from(catMap.entries())
.map(([name, count]) => ({
name,
count,
color: CATEGORY_COLORS[name] ?? CATEGORY_COLORS['Unknown'],
percentage: total > 0 ? Math.round((count / total) * 100) : 0,
}))
.sort((a, b) => b.count - a.count)
return result
}, [summary])
if (isLoading) {
return (
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
Loading category breakdown...
</div>
)
}
if (categories.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
No category data available.
</div>
)
}
const totalEvents = categories.reduce((s, c) => s + c.count, 0)
return (
<div className="flex gap-8 items-start">
{/* Left: Donut chart */}
<div className="w-56 h-56 shrink-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={categories}
dataKey="count"
nameKey="name"
cx="50%"
cy="50%"
innerRadius="60%"
outerRadius="80%"
paddingAngle={2}
stroke="none"
>
{categories.map((entry, index) => (
<Cell key={index} fill={entry.color} />
))}
</Pie>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) return null
const d = payload[0].payload as CategoryData
return (
<div
style={{
backgroundColor: 'var(--color-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '8px 12px',
}}
>
<div className="text-[var(--color-text-primary)] text-sm font-medium">{d.name}</div>
<div className="text-[var(--color-text-secondary)] text-xs mt-0.5">
{d.count} events ({d.percentage}%)
</div>
</div>
)
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
{/* Right: Legend list */}
<div className="flex-1 space-y-3 pt-2">
{categories.map((cat) => (
<div key={cat.name} className="flex items-center gap-3">
{/* Colored dot */}
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: cat.color }}
/>
{/* Name + bar + stats */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-[var(--color-text-primary)] font-medium truncate">{cat.name}</span>
<span className="text-xs text-[var(--color-text-secondary)] ml-2 shrink-0">
{cat.count} ({cat.percentage}%)
</span>
</div>
{/* Progress bar */}
<div className="h-1.5 rounded-full bg-[var(--color-border)] overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${totalEvents > 0 ? (cat.count / totalEvents) * 100 : 0}%`,
backgroundColor: cat.color,
}}
/>
</div>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -1,208 +0,0 @@
import { useMemo } from 'react'
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
} from 'recharts'
import { useRecentContext } from '../../api/context'
import { useMappings } from '../../api/mappings'
import { CATEGORY_COLORS } from '../../lib/constants'
interface TimelineProps {
minutes: number
taskId?: number
}
interface BucketData {
label: string
count: number
category: string
color: string
appName: string
timeRange: string
}
function resolveCategory(
appName: string,
mappings: { pattern: string; matchType: string; category: string }[],
): string {
for (const m of mappings) {
if (m.matchType === 'Exact' && m.pattern.toLowerCase() === appName.toLowerCase()) {
return m.category
}
if (m.matchType === 'Contains' && appName.toLowerCase().includes(m.pattern.toLowerCase())) {
return m.category
}
if (m.matchType === 'Regex') {
try {
if (new RegExp(m.pattern, 'i').test(appName)) return m.category
} catch {
// skip invalid regex
}
}
}
return 'Unknown'
}
function formatHour(date: Date): string {
const h = date.getHours()
const ampm = h >= 12 ? 'pm' : 'am'
const hour12 = h % 12 || 12
return `${hour12}${ampm}`
}
function formatTimeRange(date: Date): string {
const start = formatHour(date)
const next = new Date(date)
next.setHours(next.getHours() + 1)
const end = formatHour(next)
return `${start} - ${end}`
}
export default function Timeline({ minutes, taskId }: TimelineProps) {
const { data: events, isLoading: eventsLoading } = useRecentContext(minutes)
const { data: mappings, isLoading: mappingsLoading } = useMappings()
const buckets = useMemo(() => {
if (!events || !mappings) return []
let filtered = events
if (taskId) {
filtered = events.filter((e) => e.workTaskId === taskId)
}
if (filtered.length === 0) return []
// Group by hour bucket
const hourMap = new Map<
string,
{ date: Date; apps: Map<string, { count: number; category: string }> }
>()
for (const evt of filtered) {
const ts = new Date(evt.timestamp)
const bucketDate = new Date(ts)
bucketDate.setMinutes(0, 0, 0)
const key = bucketDate.toISOString()
if (!hourMap.has(key)) {
hourMap.set(key, { date: bucketDate, apps: new Map() })
}
const bucket = hourMap.get(key)!
const category = resolveCategory(evt.appName, mappings)
const appKey = `${evt.appName}|${category}`
if (!bucket.apps.has(appKey)) {
bucket.apps.set(appKey, { count: 0, category })
}
bucket.apps.get(appKey)!.count++
}
// Sort by time and determine dominant app per bucket
const sorted = Array.from(hourMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([, { date, apps }]): BucketData => {
let dominantApp = ''
let dominantCategory = 'Unknown'
let maxCount = 0
for (const [key, { count, category }] of apps) {
if (count > maxCount) {
maxCount = count
dominantCategory = category
dominantApp = key.split('|')[0]
}
}
const totalCount = Array.from(apps.values()).reduce((s, a) => s + a.count, 0)
return {
label: formatHour(date),
count: totalCount,
category: dominantCategory,
color: CATEGORY_COLORS[dominantCategory] ?? CATEGORY_COLORS['Unknown'],
appName: dominantApp,
timeRange: formatTimeRange(date),
}
})
return sorted
}, [events, mappings, taskId])
if (eventsLoading || mappingsLoading) {
return (
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
Loading timeline...
</div>
)
}
if (buckets.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
No activity data for this time range.
</div>
)
}
return (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={buckets} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
<XAxis
dataKey="label"
tick={{ fill: 'var(--color-text-secondary)', fontSize: 12 }}
axisLine={{ stroke: 'var(--color-border)' }}
tickLine={false}
/>
<YAxis
tick={{ fill: 'var(--color-text-secondary)', fontSize: 12 }}
axisLine={false}
tickLine={false}
allowDecimals={false}
/>
<Tooltip
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
contentStyle={{
backgroundColor: 'var(--color-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '8px 12px',
}}
labelStyle={{ display: 'none' }}
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) return null
const d = payload[0].payload as BucketData
return (
<div
style={{
backgroundColor: 'var(--color-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '8px 12px',
}}
>
<div className="text-[var(--color-text-secondary)] text-xs">{d.timeRange}</div>
<div className="text-[var(--color-text-primary)] text-sm font-medium mt-0.5">{d.appName}</div>
<div className="text-xs mt-0.5" style={{ color: d.color }}>
{d.count} events
</div>
</div>
)
}}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]} maxBarSize={40}>
{buckets.map((entry, index) => (
<Cell key={index} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -1,100 +0,0 @@
@import "tailwindcss";
@theme {
--font-sans: 'Inter', system-ui, sans-serif;
--color-page: #0a0a0f;
--color-surface: #12131a;
--color-elevated: #1a1b26;
--color-border: rgba(255, 255, 255, 0.06);
--color-border-hover: rgba(255, 255, 255, 0.12);
--color-text-primary: #e2e8f0;
--color-text-secondary: #64748b;
--color-text-tertiary: #334155;
--color-accent: #8b5cf6;
--color-accent-end: #6366f1;
--color-status-active: #3b82f6;
--color-status-paused: #eab308;
--color-status-completed: #22c55e;
--color-status-pending: #64748b;
}
/* Noise grain texture */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 256px 256px;
}
/* Active task pulse */
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 6px rgba(59, 130, 246, 0.3);
}
50% {
box-shadow: 0 0 16px rgba(59, 130, 246, 0.5);
}
}
.animate-pulse-glow {
animation: pulse-glow 2.5s ease-in-out infinite;
}
/* Live dot pulse */
@keyframes live-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.animate-live-dot {
animation: live-dot 1.5s ease-in-out infinite;
}
/* Card hover glow border */
.card-glow {
position: relative;
}
.card-glow::before {
content: '';
position: absolute;
inset: -1px;
border-radius: inherit;
background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(99, 102, 241, 0.1), transparent);
opacity: 0;
transition: opacity 0.2s ease;
z-index: -1;
pointer-events: none;
}
.card-glow:hover::before {
opacity: 1;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #1a1b26;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #2a2d37;
}
/* Selection color */
::selection {
background: rgba(139, 92, 246, 0.3);
}

View File

@@ -1,21 +0,0 @@
export const COLUMN_CONFIG = [
{ status: 'Pending' as const, label: 'Pending', color: '#64748b' },
{ status: 'Active' as const, label: 'Active', color: '#3b82f6' },
{ status: 'Paused' as const, label: 'Paused', color: '#eab308' },
{ status: 'Completed' as const, label: 'Completed', color: '#22c55e' },
] as const
export const CATEGORY_COLORS: Record<string, string> = {
Development: '#6366f1',
Research: '#06b6d4',
Communication: '#8b5cf6',
DevOps: '#f97316',
Documentation: '#14b8a6',
Design: '#ec4899',
Testing: '#3b82f6',
General: '#64748b',
Email: '#f59e0b',
Engineering: '#6366f1',
LaserCutting: '#ef4444',
Unknown: '#475569',
}

View File

@@ -1,17 +0,0 @@
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(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
)

View File

@@ -1,122 +0,0 @@
import { useState } from 'react'
import { useTasks } from '../api/tasks'
import Timeline from '../components/analytics/Timeline'
import CategoryBreakdown from '../components/analytics/CategoryBreakdown'
import ActivityFeed from '../components/analytics/ActivityFeed'
const TIME_RANGES = [
{ label: 'Today', minutes: 1440 },
{ label: '7 days', minutes: 10080 },
{ label: '30 days', minutes: 43200 },
] as const
export default function Analytics() {
const [minutes, setMinutes] = useState<number>(1440)
const [taskId, setTaskId] = useState<number | undefined>(undefined)
const { data: tasks } = useTasks()
return (
<div className="max-w-6xl mx-auto space-y-8">
{/* Header + Filters */}
<div className="flex items-center justify-between flex-wrap gap-4">
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">Analytics</h1>
<div className="flex items-center gap-3">
{/* Time range dropdown */}
<select
value={minutes}
onChange={(e) => setMinutes(Number(e.target.value))}
className="bg-[var(--color-surface)] text-[var(--color-text-primary)] text-sm rounded-lg border border-[var(--color-border)] px-3 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors appearance-none cursor-pointer"
>
{TIME_RANGES.map((r) => (
<option key={r.minutes} value={r.minutes}>
{r.label}
</option>
))}
</select>
{/* Task filter dropdown */}
<select
value={taskId ?? ''}
onChange={(e) => setTaskId(e.target.value ? Number(e.target.value) : undefined)}
className="bg-[var(--color-surface)] text-[var(--color-text-primary)] text-sm rounded-lg border border-[var(--color-border)] px-3 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors appearance-none cursor-pointer max-w-[200px]"
>
<option value="">All Tasks</option>
{tasks?.map((t) => (
<option key={t.id} value={t.id}>
{t.title}
</option>
))}
</select>
</div>
</div>
{/* Stat cards */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl p-4">
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">Open Tasks</span>
<p className="text-2xl font-semibold text-[var(--color-text-primary)] mt-1">
{tasks?.filter(t => t.status !== 'Completed' && t.status !== 'Abandoned').length ?? 0}
</p>
</div>
<div className="bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl p-4">
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">Active Time</span>
<p className="text-2xl font-semibold text-[var(--color-text-primary)] mt-1">
{(() => {
const totalMins = tasks?.reduce((acc, t) => {
if (!t.startedAt) return acc
const start = new Date(t.startedAt).getTime()
const end = t.completedAt ? new Date(t.completedAt).getTime() : (t.status === 'Active' ? Date.now() : start)
return acc + (end - start) / 60000
}, 0) ?? 0
const hours = Math.floor(totalMins / 60)
const mins = Math.floor(totalMins % 60)
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`
})()}
</p>
</div>
<div className="bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl p-4">
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">Top Category</span>
<p className="text-2xl font-semibold text-[var(--color-text-primary)] mt-1">
{(() => {
const counts: Record<string, number> = {}
tasks?.forEach(t => { counts[t.category ?? 'Unknown'] = (counts[t.category ?? 'Unknown'] || 0) + 1 })
const top = Object.entries(counts).sort(([,a], [,b]) => b - a)[0]
return top ? top[0] : '\u2014'
})()}
</p>
</div>
</div>
{/* Timeline */}
<section>
<h2 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider mb-4">
Activity Timeline
</h2>
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-5">
<Timeline minutes={minutes} taskId={taskId} />
</div>
</section>
{/* Category Breakdown */}
<section>
<h2 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider mb-4">
Category Breakdown
</h2>
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-5">
<CategoryBreakdown minutes={minutes} taskId={taskId} />
</div>
</section>
{/* Activity Feed */}
<section>
<h2 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider mb-4">
Recent Activity
</h2>
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-5">
<ActivityFeed minutes={minutes} taskId={taskId} />
</div>
</section>
</div>
)
}

View File

@@ -1,49 +0,0 @@
import { useState, useEffect, useMemo } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useTasks } from '../api/tasks.ts'
import KanbanBoard from '../components/KanbanBoard.tsx'
import TaskDetailPanel from '../components/TaskDetailPanel.tsx'
import FilterBar, { applyFilters, EMPTY_FILTERS } from '../components/FilterBar.tsx'
import type { Filters } from '../components/FilterBar.tsx'
export default function Board() {
const [selectedTaskId, setSelectedTaskId] = useState<number | null>(null)
const [searchParams, setSearchParams] = useSearchParams()
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS)
const { data: tasks, isLoading } = useTasks()
// Read ?task= search param on mount or when it changes
useEffect(() => {
const taskParam = searchParams.get('task')
if (taskParam) {
const id = Number(taskParam)
if (!isNaN(id)) {
setSelectedTaskId(id)
}
// Clean up the search param
setSearchParams({}, { replace: true })
}
}, [searchParams, setSearchParams])
// Apply filters to tasks
const filteredTasks = useMemo(() => {
if (!tasks) return []
return applyFilters(tasks, filters)
}, [tasks, filters])
return (
<div className="h-full flex flex-col">
<FilterBar tasks={tasks ?? []} filters={filters} onFiltersChange={setFilters} />
<div className="flex-1 min-h-0">
<KanbanBoard
tasks={filteredTasks}
isLoading={isLoading}
onTaskClick={(id) => setSelectedTaskId(id)}
/>
</div>
{selectedTaskId !== null && (
<TaskDetailPanel taskId={selectedTaskId} onClose={() => setSelectedTaskId(null)} />
)}
</div>
)
}

View File

@@ -1,289 +0,0 @@
import { useState } from 'react'
import { Pencil, Trash2, Check, X, Plus, Link } from 'lucide-react'
import { useMappings, useCreateMapping, useUpdateMapping, useDeleteMapping } from '../api/mappings'
import { CATEGORY_COLORS } from '../lib/constants'
import type { AppMapping } from '../types'
const MATCH_TYPES = ['ProcessName', 'TitleContains', 'UrlContains'] as const
const MATCH_TYPE_COLORS: Record<string, string> = {
ProcessName: '#6366f1',
TitleContains: '#06b6d4',
UrlContains: '#f97316',
}
interface FormData {
pattern: string
matchType: string
category: string
friendlyName: string
}
const emptyForm: FormData = { pattern: '', matchType: 'ProcessName', category: '', friendlyName: '' }
function formFromMapping(m: AppMapping): FormData {
return {
pattern: m.pattern,
matchType: m.matchType,
category: m.category,
friendlyName: m.friendlyName ?? '',
}
}
export default function Mappings() {
const { data: mappings, isLoading } = useMappings()
const createMapping = useCreateMapping()
const updateMapping = useUpdateMapping()
const deleteMapping = useDeleteMapping()
const [addingNew, setAddingNew] = useState(false)
const [newForm, setNewForm] = useState<FormData>(emptyForm)
const [editingId, setEditingId] = useState<number | null>(null)
const [editForm, setEditForm] = useState<FormData>(emptyForm)
function handleAddSave() {
if (!newForm.pattern.trim() || !newForm.category.trim()) return
createMapping.mutate(
{
pattern: newForm.pattern.trim(),
matchType: newForm.matchType,
category: newForm.category.trim(),
friendlyName: newForm.friendlyName.trim() || undefined,
},
{
onSuccess: () => {
setAddingNew(false)
setNewForm(emptyForm)
},
},
)
}
function handleAddCancel() {
setAddingNew(false)
setNewForm(emptyForm)
}
function handleEditStart(mapping: AppMapping) {
setEditingId(mapping.id)
setEditForm(formFromMapping(mapping))
// Cancel any add-new row when starting an edit
setAddingNew(false)
}
function handleEditSave() {
if (editingId === null) return
if (!editForm.pattern.trim() || !editForm.category.trim()) return
updateMapping.mutate(
{
id: editingId,
pattern: editForm.pattern.trim(),
matchType: editForm.matchType,
category: editForm.category.trim(),
friendlyName: editForm.friendlyName.trim() || undefined,
},
{
onSuccess: () => {
setEditingId(null)
setEditForm(emptyForm)
},
},
)
}
function handleEditCancel() {
setEditingId(null)
setEditForm(emptyForm)
}
function handleDelete(id: number) {
if (!window.confirm('Delete this mapping rule?')) return
deleteMapping.mutate(id)
}
const inputClass =
'bg-[var(--color-page)] text-[var(--color-text-primary)] text-sm rounded border border-[var(--color-border)] px-2 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors w-full'
const selectClass =
'bg-[var(--color-page)] text-[var(--color-text-primary)] text-sm rounded border border-[var(--color-border)] px-2 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors appearance-none cursor-pointer w-full'
function renderFormRow(form: FormData, setForm: (f: FormData) => void, onSave: () => void, onCancel: () => void, isSaving: boolean) {
return (
<tr className="bg-white/[0.04]">
<td className="px-4 py-3">
<input
type="text"
placeholder="Pattern..."
value={form.pattern}
onChange={(e) => setForm({ ...form, pattern: e.target.value })}
className={inputClass}
autoFocus
/>
</td>
<td className="px-4 py-3">
<select
value={form.matchType}
onChange={(e) => setForm({ ...form, matchType: e.target.value })}
className={selectClass}
>
{MATCH_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</td>
<td className="px-4 py-3">
<input
type="text"
placeholder="Category..."
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
className={inputClass}
/>
</td>
<td className="px-4 py-3">
<input
type="text"
placeholder="Friendly name (optional)"
value={form.friendlyName}
onChange={(e) => setForm({ ...form, friendlyName: e.target.value })}
className={inputClass}
/>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<button
onClick={onSave}
disabled={isSaving}
className="p-1.5 rounded text-emerald-400 hover:bg-emerald-400/10 transition-colors disabled:opacity-50"
title="Save"
>
<Check size={16} />
</button>
<button
onClick={onCancel}
className="p-1.5 rounded text-[var(--color-text-secondary)] hover:bg-white/5 transition-colors"
title="Cancel"
>
<X size={16} />
</button>
</div>
</td>
</tr>
)
}
return (
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">App Mappings</h1>
<button
onClick={() => {
setAddingNew(true)
setEditingId(null)
setNewForm(emptyForm)
}}
disabled={addingNew}
className="flex items-center gap-1.5 bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 disabled:opacity-50 text-white text-sm font-medium px-3 py-1.5 rounded-lg transition-all"
>
<Plus size={16} />
Add Rule
</button>
</div>
{/* Table */}
{isLoading ? (
<div className="text-[var(--color-text-secondary)] text-sm py-12 text-center">Loading mappings...</div>
) : !mappings?.length && !addingNew ? (
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-12 text-center">
<Link size={40} className="text-[var(--color-text-tertiary)] mx-auto mb-3" />
<p className="text-[var(--color-text-secondary)] text-sm mb-3">No mappings configured</p>
<button
onClick={() => {
setAddingNew(true)
setNewForm(emptyForm)
}}
className="text-[var(--color-accent)] hover:brightness-110 text-sm font-medium transition-all"
>
+ Add your first mapping rule
</button>
</div>
) : (
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--color-surface)] border-b border-[var(--color-border)]">
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Pattern</th>
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Match Type</th>
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Category</th>
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Friendly Name</th>
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium w-24">Actions</th>
</tr>
</thead>
<tbody>
{/* Add-new row */}
{addingNew &&
renderFormRow(newForm, setNewForm, handleAddSave, handleAddCancel, createMapping.isPending)}
{/* Data rows */}
{mappings?.map((m) =>
editingId === m.id ? (
renderFormRow(editForm, setEditForm, handleEditSave, handleEditCancel, updateMapping.isPending)
) : (
<tr
key={m.id}
className="border-b border-[var(--color-border)] hover:bg-white/[0.03] transition-colors"
>
<td className="px-4 py-3 text-[var(--color-text-primary)] font-mono text-xs">{m.pattern}</td>
<td className="px-4 py-3">
<span
className="inline-block text-xs font-medium px-2 py-0.5 rounded-full"
style={{
backgroundColor: `${MATCH_TYPE_COLORS[m.matchType] ?? '#64748b'}20`,
color: MATCH_TYPE_COLORS[m.matchType] ?? '#64748b',
}}
>
{m.matchType}
</span>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-1.5 text-[var(--color-text-primary)] text-xs">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: CATEGORY_COLORS[m.category] ?? '#64748b' }}
/>
{m.category}
</span>
</td>
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
{m.friendlyName ?? '\u2014'}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<button
onClick={() => handleEditStart(m)}
className="p-1.5 rounded text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-white/5 transition-colors"
title="Edit"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDelete(m.id)}
className="p-1.5 rounded text-[var(--color-text-secondary)] hover:text-rose-400 hover:bg-rose-400/10 transition-colors"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
),
)}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -1,71 +0,0 @@
export const WorkTaskStatus = {
Pending: 'Pending',
Active: 'Active',
Paused: 'Paused',
Completed: 'Completed',
Abandoned: 'Abandoned',
} as const
export type WorkTaskStatus = (typeof WorkTaskStatus)[keyof typeof WorkTaskStatus]
export const NoteType = {
PauseNote: 'PauseNote',
ResumeNote: 'ResumeNote',
General: 'General',
} 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<T> {
success: boolean
data: T
error: string | null
}

View File

@@ -1,28 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -1,26 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,16 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:5200',
changeOrigin: true,
},
},
},
})