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