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:
24
TaskTracker.Web/.gitignore
vendored
24
TaskTracker.Web/.gitignore
vendored
@@ -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?
|
|
||||||
@@ -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...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
@@ -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>
|
|
||||||
4796
TaskTracker.Web/package-lock.json
generated
4796
TaskTracker.Web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 |
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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'] }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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'] }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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">↑↓</kbd>
|
|
||||||
Navigate
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<kbd className="font-mono bg-white/[0.06] px-1 py-0.5 rounded">↵</kbd>
|
|
||||||
Open
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
}
|
|
||||||
@@ -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>,
|
|
||||||
)
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user