@fayz-ai/plugin-tasks 0.9.0-next.1 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/components/TasksTopbarButton.d.ts +1 -1
- package/dist/components/TasksTopbarButton.d.ts.map +1 -1
- package/dist/{TasksContext.d.ts → context.d.ts} +1 -1
- package/dist/context.d.ts.map +1 -0
- package/dist/data/registries.d.ts.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/package.json +5 -7
- package/dist/TasksContext.d.ts.map +0 -1
- package/dist/registries.d.ts.map +0 -1
- package/src/TasksContext.tsx +0 -24
- package/src/components/TaskCard.tsx +0 -81
- package/src/components/TaskDetail.tsx +0 -209
- package/src/components/TaskLabelBadge.tsx +0 -37
- package/src/components/TaskPriorityBadge.tsx +0 -21
- package/src/components/TaskQuickAdd.tsx +0 -42
- package/src/components/TaskStatusGroup.tsx +0 -44
- package/src/components/TasksDrawer.tsx +0 -32
- package/src/components/TasksFilterBar.tsx +0 -57
- package/src/components/TasksGeneralSettings.tsx +0 -41
- package/src/components/TasksPanel.tsx +0 -94
- package/src/components/TasksTopbarButton.tsx +0 -109
- package/src/data/capability.test.ts +0 -43
- package/src/data/mock.ts +0 -143
- package/src/data/supabase.ts +0 -194
- package/src/data/tables.ts +0 -6
- package/src/data/types.ts +0 -16
- package/src/index.ts +0 -202
- package/src/lib/tenant.ts +0 -13
- package/src/locales/en.ts +0 -42
- package/src/locales/index.ts +0 -7
- package/src/locales/pt-BR.ts +0 -42
- package/src/migrations/000_plg_rename.sql +0 -12
- package/src/migrations/001_tasks_base.sql +0 -65
- package/src/migrations/index.ts +0 -89
- package/src/registries.ts +0 -33
- package/src/store.ts +0 -148
- package/src/types.ts +0 -94
- /package/dist/{registries.d.ts → data/registries.d.ts} +0 -0
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import { useTasksStore } from '../TasksContext'
|
|
3
|
-
import { useTranslation } from '@fayz-ai/core'
|
|
4
|
-
import { TasksFilterBar } from './TasksFilterBar'
|
|
5
|
-
import { TaskQuickAdd } from './TaskQuickAdd'
|
|
6
|
-
import { TaskCard } from './TaskCard'
|
|
7
|
-
import { TaskStatusGroup } from './TaskStatusGroup'
|
|
8
|
-
import { TaskDetail } from './TaskDetail'
|
|
9
|
-
import { ClipboardList } from 'lucide-react'
|
|
10
|
-
import type { TaskStatus } from '../types'
|
|
11
|
-
|
|
12
|
-
const STATUS_ORDER: TaskStatus[] = ['todo', 'in_progress', 'done', 'cancelled']
|
|
13
|
-
|
|
14
|
-
/** The task list with no box around it — shared by the sheet and the shell's
|
|
15
|
-
* right-rail tab. */
|
|
16
|
-
export function TasksPanel() {
|
|
17
|
-
const t = useTranslation()
|
|
18
|
-
const selectedTaskId = useTasksStore((s) => s.selectedTaskId)
|
|
19
|
-
const tasks = useTasksStore((s) => s.tasks)
|
|
20
|
-
const tasksLoading = useTasksStore((s) => s.tasksLoading)
|
|
21
|
-
const activeFilter = useTasksStore((s) => s.activeFilter)
|
|
22
|
-
const fetchTasks = useTasksStore((s) => s.fetchTasks)
|
|
23
|
-
const fetchLabels = useTasksStore((s) => s.fetchLabels)
|
|
24
|
-
const fetchSummary = useTasksStore((s) => s.fetchSummary)
|
|
25
|
-
|
|
26
|
-
// The rail tab mounts this panel directly — openDrawer() (the sheet path,
|
|
27
|
-
// where fetching used to live) never runs there, so load on mount. Fetches
|
|
28
|
-
// are deduped in the store, so the drawer path doesn't double-load.
|
|
29
|
-
React.useEffect(() => {
|
|
30
|
-
void fetchTasks()
|
|
31
|
-
void fetchLabels()
|
|
32
|
-
void fetchSummary()
|
|
33
|
-
}, [fetchTasks, fetchLabels, fetchSummary])
|
|
34
|
-
|
|
35
|
-
const grouped = React.useMemo(() => {
|
|
36
|
-
const map = new Map<TaskStatus, typeof tasks>()
|
|
37
|
-
for (const s of STATUS_ORDER) map.set(s, [])
|
|
38
|
-
for (const task of tasks) {
|
|
39
|
-
const group = map.get(task.status)
|
|
40
|
-
if (group) group.push(task)
|
|
41
|
-
}
|
|
42
|
-
return map
|
|
43
|
-
}, [tasks])
|
|
44
|
-
|
|
45
|
-
return (
|
|
46
|
-
<>
|
|
47
|
-
{!selectedTaskId && <TasksFilterBar />}
|
|
48
|
-
{!selectedTaskId && <TaskQuickAdd />}
|
|
49
|
-
|
|
50
|
-
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
51
|
-
{selectedTaskId ? (
|
|
52
|
-
<TaskDetail taskId={selectedTaskId} />
|
|
53
|
-
) : tasksLoading && tasks.length === 0 ? (
|
|
54
|
-
<div className="flex items-center justify-center py-16">
|
|
55
|
-
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
56
|
-
</div>
|
|
57
|
-
) : tasks.length === 0 ? (
|
|
58
|
-
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
|
59
|
-
<ClipboardList className="mb-3 h-10 w-10 opacity-40" />
|
|
60
|
-
<p className="text-sm font-medium">{t('tasks.empty')}</p>
|
|
61
|
-
<p className="mt-1 text-xs">{t('tasks.empty.description')}</p>
|
|
62
|
-
</div>
|
|
63
|
-
) : activeFilter !== 'all' ? (
|
|
64
|
-
<div className="py-1">
|
|
65
|
-
{tasks.map((task) => (
|
|
66
|
-
<div key={task.id} className="px-2">
|
|
67
|
-
<TaskCard task={task} />
|
|
68
|
-
</div>
|
|
69
|
-
))}
|
|
70
|
-
</div>
|
|
71
|
-
) : (
|
|
72
|
-
<div className="space-y-2 py-2">
|
|
73
|
-
{STATUS_ORDER.filter((s) => s !== 'cancelled' || (grouped.get(s)?.length ?? 0) > 0).map((status) => (
|
|
74
|
-
<TaskStatusGroup key={status} status={status} tasks={grouped.get(status) ?? []} />
|
|
75
|
-
))}
|
|
76
|
-
</div>
|
|
77
|
-
)}
|
|
78
|
-
</div>
|
|
79
|
-
</>
|
|
80
|
-
)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** One-line summary of what needs attention ("3 atrasadas · 2 para hoje"). */
|
|
84
|
-
export function useTasksSummaryLine(): string {
|
|
85
|
-
const t = useTranslation()
|
|
86
|
-
const summary = useTasksStore((s) => s.summary)
|
|
87
|
-
return React.useMemo(() => {
|
|
88
|
-
if (!summary) return ''
|
|
89
|
-
const parts: string[] = []
|
|
90
|
-
if (summary.overdue > 0) parts.push(`${summary.overdue} ${t('tasks.summary.overdue')}`)
|
|
91
|
-
if (summary.dueToday > 0) parts.push(`${summary.dueToday} ${t('tasks.summary.dueToday')}`)
|
|
92
|
-
return parts.join(' · ')
|
|
93
|
-
}, [summary, t])
|
|
94
|
-
}
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
import React, { useEffect } from 'react'
|
|
2
|
-
import { CheckSquare } from 'lucide-react'
|
|
3
|
-
import { Button } from '@fayz-ai/ui'
|
|
4
|
-
import { cn } from '@fayz-ai/ui'
|
|
5
|
-
import { useRightRailPanel, useRightRailStore } from '@fayz-ai/saas'
|
|
6
|
-
import { useTranslation } from '@fayz-ai/core'
|
|
7
|
-
import { TasksContextProvider, type ResolvedTasksConfig } from '../TasksContext'
|
|
8
|
-
import { TasksDrawer } from './TasksDrawer'
|
|
9
|
-
import { TasksPanel } from './TasksPanel'
|
|
10
|
-
import type { TasksDataProvider } from '../data/types'
|
|
11
|
-
import type { TasksUIState } from '../store'
|
|
12
|
-
import { useStore, type StoreApi } from 'zustand'
|
|
13
|
-
|
|
14
|
-
interface TasksTopbarWidgetProps {
|
|
15
|
-
config: {
|
|
16
|
-
tasksConfig: ResolvedTasksConfig
|
|
17
|
-
tasksProvider: TasksDataProvider
|
|
18
|
-
tasksStore: StoreApi<TasksUIState>
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function TasksTopbarButton({ config }: TasksTopbarWidgetProps) {
|
|
23
|
-
const { tasksConfig, tasksProvider, tasksStore } = config
|
|
24
|
-
const t = useTranslation()
|
|
25
|
-
// The shell's rail when there is one, this plugin's sheet when there is not.
|
|
26
|
-
const railMounted = useRightRailStore((s) => s.mounted)
|
|
27
|
-
// Open tasks (todo + in progress) — the rail tab's red badge.
|
|
28
|
-
const summary = useStore(tasksStore, (s) => s.summary)
|
|
29
|
-
const openCount = summary ? summary.todo + summary.inProgress : 0
|
|
30
|
-
|
|
31
|
-
// Carries its own providers (registered far from here) and must stay
|
|
32
|
-
// referentially stable or the list remounts every render.
|
|
33
|
-
const Panel = React.useMemo(() => {
|
|
34
|
-
const RailPanel = () => (
|
|
35
|
-
<TasksContextProvider config={tasksConfig} provider={tasksProvider} store={tasksStore}>
|
|
36
|
-
<TasksPanel />
|
|
37
|
-
</TasksContextProvider>
|
|
38
|
-
)
|
|
39
|
-
RailPanel.displayName = 'TasksRailPanel'
|
|
40
|
-
return RailPanel
|
|
41
|
-
}, [tasksConfig, tasksProvider, tasksStore])
|
|
42
|
-
|
|
43
|
-
useRightRailPanel(
|
|
44
|
-
React.useMemo(
|
|
45
|
-
() =>
|
|
46
|
-
railMounted
|
|
47
|
-
? {
|
|
48
|
-
id: 'tasks',
|
|
49
|
-
label: t('tasks.drawer.title') || 'Tasks',
|
|
50
|
-
icon: CheckSquare,
|
|
51
|
-
order: 20,
|
|
52
|
-
badge: { count: openCount, tone: 'destructive' as const },
|
|
53
|
-
Component: Panel,
|
|
54
|
-
}
|
|
55
|
-
: null,
|
|
56
|
-
[railMounted, t, Panel, openCount],
|
|
57
|
-
),
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
return (
|
|
61
|
-
<TasksContextProvider config={tasksConfig} provider={tasksProvider} store={tasksStore}>
|
|
62
|
-
<TasksTopbarButtonInner store={tasksStore} railMounted={railMounted} />
|
|
63
|
-
{!railMounted && <TasksDrawer />}
|
|
64
|
-
</TasksContextProvider>
|
|
65
|
-
)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function TasksTopbarButtonInner({
|
|
69
|
-
store,
|
|
70
|
-
railMounted,
|
|
71
|
-
}: {
|
|
72
|
-
store: StoreApi<TasksUIState>
|
|
73
|
-
railMounted: boolean
|
|
74
|
-
}) {
|
|
75
|
-
const toggleDrawer = useStore(store, (s) => s.toggleDrawer)
|
|
76
|
-
const fetchSummary = useStore(store, (s) => s.fetchSummary)
|
|
77
|
-
const summary = useStore(store, (s) => s.summary)
|
|
78
|
-
const togglePanel = useRightRailStore((s) => s.togglePanel)
|
|
79
|
-
|
|
80
|
-
useEffect(() => {
|
|
81
|
-
fetchSummary()
|
|
82
|
-
}, [fetchSummary])
|
|
83
|
-
|
|
84
|
-
const pendingCount = summary ? summary.todo + summary.inProgress : 0
|
|
85
|
-
|
|
86
|
-
return (
|
|
87
|
-
<Button
|
|
88
|
-
variant="ghost"
|
|
89
|
-
size="icon"
|
|
90
|
-
className={cn('relative text-sidebar-foreground/70 hover:bg-sidebar/30 hover:text-sidebar-foreground')}
|
|
91
|
-
onClick={() => (railMounted ? togglePanel('tasks') : toggleDrawer())}
|
|
92
|
-
aria-label="Tasks"
|
|
93
|
-
>
|
|
94
|
-
<CheckSquare className="h-5 w-5" />
|
|
95
|
-
{pendingCount > 0 && (
|
|
96
|
-
<span
|
|
97
|
-
className={cn(
|
|
98
|
-
'absolute -right-0.5 -top-0.5 flex items-center justify-center rounded-full bg-destructive text-destructive-foreground',
|
|
99
|
-
pendingCount > 9
|
|
100
|
-
? 'h-5 min-w-5 px-1 text-[10px]'
|
|
101
|
-
: 'h-4 w-4 text-[10px]'
|
|
102
|
-
)}
|
|
103
|
-
>
|
|
104
|
-
{pendingCount > 99 ? '99+' : pendingCount}
|
|
105
|
-
</span>
|
|
106
|
-
)}
|
|
107
|
-
</Button>
|
|
108
|
-
)
|
|
109
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
// First end-to-end capability test for any Fayz plugin (FAY-1206).
|
|
2
|
-
// Proves the data slice of the Plugin Capability Contract against the mock
|
|
3
|
-
// provider: typed seed is present, writes persist and surface in queries and
|
|
4
|
-
// the summary, and updates/deletes round-trip. This is the template other
|
|
5
|
-
// plugins copy — see docs/PLUGIN-PATTERNS.md → capability anatomy.
|
|
6
|
-
import { describe, it, expect } from 'vitest'
|
|
7
|
-
import { createMockTasksProvider } from './mock'
|
|
8
|
-
|
|
9
|
-
describe('plugin-tasks capability · data slice (mock provider)', () => {
|
|
10
|
-
it('ships typed seed (labels are provisioned, not empty)', async () => {
|
|
11
|
-
const provider = createMockTasksProvider()
|
|
12
|
-
const labels = await provider.getLabels()
|
|
13
|
-
expect(labels).toHaveLength(4)
|
|
14
|
-
expect(labels.map((l) => l.name)).toContain('Bug')
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
it('persists a created task: it surfaces in queries and the summary', async () => {
|
|
18
|
-
const provider = createMockTasksProvider()
|
|
19
|
-
const before = await provider.getSummary()
|
|
20
|
-
|
|
21
|
-
const task = await provider.createTask({ title: 'Wire capability gate', status: 'todo', priority: 'high' })
|
|
22
|
-
expect(task.id).toBeTruthy()
|
|
23
|
-
expect(task.tenantId).toBe('mock')
|
|
24
|
-
|
|
25
|
-
const todo = await provider.getTasks({ status: 'todo' })
|
|
26
|
-
expect(todo.some((t) => t.id === task.id)).toBe(true)
|
|
27
|
-
|
|
28
|
-
const after = await provider.getSummary()
|
|
29
|
-
expect(after.total).toBe(before.total + 1)
|
|
30
|
-
expect(after.highPriority).toBe(before.highPriority + 1)
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
it('round-trips update and delete', async () => {
|
|
34
|
-
const provider = createMockTasksProvider()
|
|
35
|
-
const task = await provider.createTask({ title: 'temp' })
|
|
36
|
-
|
|
37
|
-
const moved = await provider.updateTask(task.id, { status: 'done' })
|
|
38
|
-
expect(moved.status).toBe('done')
|
|
39
|
-
|
|
40
|
-
await provider.deleteTask(task.id)
|
|
41
|
-
expect(await provider.getTaskById(task.id)).toBeNull()
|
|
42
|
-
})
|
|
43
|
-
})
|
package/src/data/mock.ts
DELETED
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import type { TasksDataProvider } from './types'
|
|
2
|
-
import type {
|
|
3
|
-
Task, TaskLabel, CreateTaskInput, UpdateTaskInput,
|
|
4
|
-
TaskQuery, TasksSummary, TaskStatus,
|
|
5
|
-
} from '../types'
|
|
6
|
-
|
|
7
|
-
function uid(): string { return crypto.randomUUID?.() ?? String(Date.now()) }
|
|
8
|
-
function now(): string { return new Date().toISOString() }
|
|
9
|
-
function today(): string { return new Date().toISOString().slice(0, 10) }
|
|
10
|
-
|
|
11
|
-
const SEED_LABELS: TaskLabel[] = [
|
|
12
|
-
{ id: 'lbl-bug', name: 'Bug', color: '#ef4444', isActive: true, tenantId: 'mock', createdAt: now() },
|
|
13
|
-
{ id: 'lbl-feature', name: 'Feature', color: '#3b82f6', isActive: true, tenantId: 'mock', createdAt: now() },
|
|
14
|
-
{ id: 'lbl-urgent', name: 'Urgent', color: '#f59e0b', isActive: true, tenantId: 'mock', createdAt: now() },
|
|
15
|
-
{ id: 'lbl-personal', name: 'Personal', color: '#8b5cf6', isActive: true, tenantId: 'mock', createdAt: now() },
|
|
16
|
-
]
|
|
17
|
-
|
|
18
|
-
function makeSeedTasks(): Task[] {
|
|
19
|
-
const base = {
|
|
20
|
-
tenantId: 'mock',
|
|
21
|
-
description: null,
|
|
22
|
-
assignedToId: null,
|
|
23
|
-
assignedToName: null,
|
|
24
|
-
parentId: null,
|
|
25
|
-
labels: [],
|
|
26
|
-
createdById: null,
|
|
27
|
-
createdByName: null,
|
|
28
|
-
createdAt: now(),
|
|
29
|
-
updatedAt: now(),
|
|
30
|
-
}
|
|
31
|
-
return [
|
|
32
|
-
{ ...base, id: uid(), title: 'Setup payment gateway', status: 'todo' as const, priority: 'high' as const, dueDate: today(), position: 0 },
|
|
33
|
-
{ ...base, id: uid(), title: 'Update menu photos', status: 'todo' as const, priority: 'medium' as const, dueDate: null, position: 1 },
|
|
34
|
-
{ ...base, id: uid(), title: 'Review monthly report', status: 'in_progress' as const, priority: 'medium' as const, dueDate: null, position: 0 },
|
|
35
|
-
{ ...base, id: uid(), title: 'Fix login bug', status: 'in_progress' as const, priority: 'urgent' as const, dueDate: today(), position: 1, labels: ['lbl-bug'] },
|
|
36
|
-
{ ...base, id: uid(), title: 'Onboard new employee', status: 'done' as const, priority: 'low' as const, dueDate: null, position: 0 },
|
|
37
|
-
]
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function matchesQuery(task: Task, query: TaskQuery): boolean {
|
|
41
|
-
if (query.status) {
|
|
42
|
-
const statuses = Array.isArray(query.status) ? query.status : [query.status]
|
|
43
|
-
if (!statuses.includes(task.status)) return false
|
|
44
|
-
}
|
|
45
|
-
if (query.priority) {
|
|
46
|
-
const priorities = Array.isArray(query.priority) ? query.priority : [query.priority]
|
|
47
|
-
if (!priorities.includes(task.priority)) return false
|
|
48
|
-
}
|
|
49
|
-
if (query.assignedToId && task.assignedToId !== query.assignedToId) return false
|
|
50
|
-
if (query.parentId !== undefined) {
|
|
51
|
-
if (query.parentId === null && task.parentId !== null) return false
|
|
52
|
-
if (query.parentId !== null && task.parentId !== query.parentId) return false
|
|
53
|
-
}
|
|
54
|
-
if (query.search) {
|
|
55
|
-
const s = query.search.toLowerCase()
|
|
56
|
-
if (!task.title.toLowerCase().includes(s)) return false
|
|
57
|
-
}
|
|
58
|
-
if (query.overdue && task.dueDate) {
|
|
59
|
-
if (task.dueDate >= today()) return false
|
|
60
|
-
}
|
|
61
|
-
return true
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function createMockTasksProvider(): TasksDataProvider {
|
|
65
|
-
let tasks = makeSeedTasks()
|
|
66
|
-
const labels = [...SEED_LABELS]
|
|
67
|
-
|
|
68
|
-
return {
|
|
69
|
-
async getTasks(query) {
|
|
70
|
-
return tasks.filter((t) => matchesQuery(t, query)).sort((a, b) => a.position - b.position)
|
|
71
|
-
},
|
|
72
|
-
|
|
73
|
-
async getTaskById(id) {
|
|
74
|
-
return tasks.find((t) => t.id === id) ?? null
|
|
75
|
-
},
|
|
76
|
-
|
|
77
|
-
async getSubtasks(parentId) {
|
|
78
|
-
return tasks.filter((t) => t.parentId === parentId).sort((a, b) => a.position - b.position)
|
|
79
|
-
},
|
|
80
|
-
|
|
81
|
-
async createTask(input: CreateTaskInput) {
|
|
82
|
-
const statusTasks = tasks.filter((t) => t.status === (input.status ?? 'todo') && t.parentId === (input.parentId ?? null))
|
|
83
|
-
const maxPos = statusTasks.reduce((max, t) => Math.max(max, t.position), -1)
|
|
84
|
-
const task: Task = {
|
|
85
|
-
id: uid(),
|
|
86
|
-
title: input.title,
|
|
87
|
-
description: input.description ?? null,
|
|
88
|
-
status: input.status ?? 'todo',
|
|
89
|
-
priority: input.priority ?? 'medium',
|
|
90
|
-
dueDate: input.dueDate ?? null,
|
|
91
|
-
assignedToId: input.assignedToId ?? null,
|
|
92
|
-
assignedToName: null,
|
|
93
|
-
parentId: input.parentId ?? null,
|
|
94
|
-
labels: input.labels ?? [],
|
|
95
|
-
position: maxPos + 1,
|
|
96
|
-
createdById: null,
|
|
97
|
-
createdByName: null,
|
|
98
|
-
tenantId: 'mock',
|
|
99
|
-
createdAt: now(),
|
|
100
|
-
updatedAt: now(),
|
|
101
|
-
}
|
|
102
|
-
tasks.push(task)
|
|
103
|
-
return task
|
|
104
|
-
},
|
|
105
|
-
|
|
106
|
-
async updateTask(id, input: UpdateTaskInput) {
|
|
107
|
-
const idx = tasks.findIndex((t) => t.id === id)
|
|
108
|
-
if (idx === -1) throw new Error('Task not found')
|
|
109
|
-
tasks[idx] = { ...tasks[idx], ...input, updatedAt: now() } as Task
|
|
110
|
-
return tasks[idx]
|
|
111
|
-
},
|
|
112
|
-
|
|
113
|
-
async deleteTask(id) {
|
|
114
|
-
tasks = tasks.filter((t) => t.id !== id && t.parentId !== id)
|
|
115
|
-
},
|
|
116
|
-
|
|
117
|
-
async reorderTask(id, newPosition, status) {
|
|
118
|
-
const task = tasks.find((t) => t.id === id)
|
|
119
|
-
if (!task) return
|
|
120
|
-
task.status = status
|
|
121
|
-
task.position = newPosition
|
|
122
|
-
task.updatedAt = now()
|
|
123
|
-
},
|
|
124
|
-
|
|
125
|
-
async getLabels() {
|
|
126
|
-
return labels.filter((l) => l.isActive)
|
|
127
|
-
},
|
|
128
|
-
|
|
129
|
-
async getSummary() {
|
|
130
|
-
const topLevel = tasks.filter((t) => t.parentId === null)
|
|
131
|
-
const d = today()
|
|
132
|
-
return {
|
|
133
|
-
total: topLevel.length,
|
|
134
|
-
todo: topLevel.filter((t) => t.status === 'todo').length,
|
|
135
|
-
inProgress: topLevel.filter((t) => t.status === 'in_progress').length,
|
|
136
|
-
done: topLevel.filter((t) => t.status === 'done').length,
|
|
137
|
-
overdue: topLevel.filter((t) => t.dueDate != null && t.dueDate < d && t.status !== 'done' && t.status !== 'cancelled').length,
|
|
138
|
-
dueToday: topLevel.filter((t) => t.dueDate === d && t.status !== 'done' && t.status !== 'cancelled').length,
|
|
139
|
-
highPriority: topLevel.filter((t) => (t.priority === 'high' || t.priority === 'urgent') && t.status !== 'done' && t.status !== 'cancelled').length,
|
|
140
|
-
}
|
|
141
|
-
},
|
|
142
|
-
}
|
|
143
|
-
}
|
package/src/data/supabase.ts
DELETED
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
import type { TasksDataProvider } from './types'
|
|
2
|
-
import type { Task, TaskLabel, TasksSummary, TaskStatus } from '../types'
|
|
3
|
-
import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
|
|
4
|
-
import { getTasksTenantId } from '../lib/tenant'
|
|
5
|
-
import { T } from './tables'
|
|
6
|
-
|
|
7
|
-
function getTenantId(): string | undefined {
|
|
8
|
-
// Local override wins; else use the app's active tenant so writes pass RLS.
|
|
9
|
-
return getTasksTenantId() ?? getActiveTenantId()
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function getClient() {
|
|
13
|
-
const supabase = getSupabaseClientOptional() as any
|
|
14
|
-
if (!supabase) throw new Error('Supabase not initialized')
|
|
15
|
-
return supabase
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function snakeToCamel(obj: Record<string, unknown>): Record<string, unknown> {
|
|
19
|
-
const result: Record<string, unknown> = {}
|
|
20
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
21
|
-
result[key.replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = value
|
|
22
|
-
}
|
|
23
|
-
return result
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function toTask(row: Record<string, unknown>): Task {
|
|
27
|
-
const t = snakeToCamel(row) as unknown as Task
|
|
28
|
-
// Ensure labels is always an array
|
|
29
|
-
if (!Array.isArray(t.labels)) t.labels = []
|
|
30
|
-
return t
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function createSupabaseTasksProvider(): TasksDataProvider {
|
|
34
|
-
return {
|
|
35
|
-
async getTasks(query) {
|
|
36
|
-
const tenantId = getTenantId()
|
|
37
|
-
if (!tenantId) return []
|
|
38
|
-
let q = getClient()
|
|
39
|
-
.from(T.tasks)
|
|
40
|
-
.select('*')
|
|
41
|
-
.eq('tenant_id', tenantId)
|
|
42
|
-
|
|
43
|
-
if (query.parentId === null) {
|
|
44
|
-
q = q.is('parent_id', null)
|
|
45
|
-
} else if (query.parentId) {
|
|
46
|
-
q = q.eq('parent_id', query.parentId)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (query.status) {
|
|
50
|
-
const statuses = Array.isArray(query.status) ? query.status : [query.status]
|
|
51
|
-
q = q.in('status', statuses)
|
|
52
|
-
}
|
|
53
|
-
if (query.priority) {
|
|
54
|
-
const priorities = Array.isArray(query.priority) ? query.priority : [query.priority]
|
|
55
|
-
q = q.in('priority', priorities)
|
|
56
|
-
}
|
|
57
|
-
if (query.assignedToId) {
|
|
58
|
-
q = q.eq('assigned_to_id', query.assignedToId)
|
|
59
|
-
}
|
|
60
|
-
if (query.search) {
|
|
61
|
-
q = q.ilike('title', `%${query.search}%`)
|
|
62
|
-
}
|
|
63
|
-
if (query.overdue) {
|
|
64
|
-
q = q.lt('due_date', new Date().toISOString().slice(0, 10))
|
|
65
|
-
.not('status', 'in', '("done","cancelled")')
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
q = q.order('position', { ascending: true }).order('created_at', { ascending: false })
|
|
69
|
-
|
|
70
|
-
const { data } = await q
|
|
71
|
-
return (data ?? []).map(toTask)
|
|
72
|
-
},
|
|
73
|
-
|
|
74
|
-
async getTaskById(id) {
|
|
75
|
-
const { data } = await getClient()
|
|
76
|
-
.from(T.tasks)
|
|
77
|
-
.select('*')
|
|
78
|
-
.eq('id', id)
|
|
79
|
-
.maybeSingle()
|
|
80
|
-
return data ? toTask(data) : null
|
|
81
|
-
},
|
|
82
|
-
|
|
83
|
-
async getSubtasks(parentId) {
|
|
84
|
-
const { data } = await getClient()
|
|
85
|
-
.from(T.tasks)
|
|
86
|
-
.select('*')
|
|
87
|
-
.eq('parent_id', parentId)
|
|
88
|
-
.order('position', { ascending: true })
|
|
89
|
-
return (data ?? []).map(toTask)
|
|
90
|
-
},
|
|
91
|
-
|
|
92
|
-
async createTask(input) {
|
|
93
|
-
const tenantId = getTenantId()
|
|
94
|
-
if (!tenantId) throw new Error('No active tenant')
|
|
95
|
-
|
|
96
|
-
// Get max position for the status group
|
|
97
|
-
const { data: maxData } = await getClient()
|
|
98
|
-
.from(T.tasks)
|
|
99
|
-
.select('position')
|
|
100
|
-
.eq('tenant_id', tenantId)
|
|
101
|
-
.eq('status', input.status ?? 'todo')
|
|
102
|
-
.is('parent_id', input.parentId ?? null)
|
|
103
|
-
.order('position', { ascending: false })
|
|
104
|
-
.limit(1)
|
|
105
|
-
const maxPos = maxData?.[0]?.position ?? -1
|
|
106
|
-
|
|
107
|
-
const { data, error } = await getClient()
|
|
108
|
-
.from(T.tasks)
|
|
109
|
-
.insert({
|
|
110
|
-
tenant_id: tenantId,
|
|
111
|
-
title: input.title,
|
|
112
|
-
description: input.description ?? null,
|
|
113
|
-
status: input.status ?? 'todo',
|
|
114
|
-
priority: input.priority ?? 'medium',
|
|
115
|
-
due_date: input.dueDate ?? null,
|
|
116
|
-
assigned_to_id: input.assignedToId ?? null,
|
|
117
|
-
parent_id: input.parentId ?? null,
|
|
118
|
-
labels: input.labels ?? [],
|
|
119
|
-
position: maxPos + 1,
|
|
120
|
-
})
|
|
121
|
-
.select('*')
|
|
122
|
-
.single()
|
|
123
|
-
if (error) throw error
|
|
124
|
-
return toTask(data)
|
|
125
|
-
},
|
|
126
|
-
|
|
127
|
-
async updateTask(id, input) {
|
|
128
|
-
const payload: Record<string, unknown> = { updated_at: new Date().toISOString() }
|
|
129
|
-
if (input.title !== undefined) payload.title = input.title
|
|
130
|
-
if (input.description !== undefined) payload.description = input.description
|
|
131
|
-
if (input.status !== undefined) payload.status = input.status
|
|
132
|
-
if (input.priority !== undefined) payload.priority = input.priority
|
|
133
|
-
if (input.dueDate !== undefined) payload.due_date = input.dueDate
|
|
134
|
-
if (input.assignedToId !== undefined) payload.assigned_to_id = input.assignedToId
|
|
135
|
-
if (input.labels !== undefined) payload.labels = input.labels
|
|
136
|
-
if (input.position !== undefined) payload.position = input.position
|
|
137
|
-
|
|
138
|
-
const { data, error } = await getClient()
|
|
139
|
-
.from(T.tasks)
|
|
140
|
-
.update(payload)
|
|
141
|
-
.eq('id', id)
|
|
142
|
-
.select('*')
|
|
143
|
-
.single()
|
|
144
|
-
if (error) throw error
|
|
145
|
-
return toTask(data)
|
|
146
|
-
},
|
|
147
|
-
|
|
148
|
-
async deleteTask(id) {
|
|
149
|
-
await getClient().from(T.tasks).delete().eq('id', id)
|
|
150
|
-
},
|
|
151
|
-
|
|
152
|
-
async reorderTask(id, newPosition, status) {
|
|
153
|
-
await getClient()
|
|
154
|
-
.from(T.tasks)
|
|
155
|
-
.update({ position: newPosition, status, updated_at: new Date().toISOString() })
|
|
156
|
-
.eq('id', id)
|
|
157
|
-
},
|
|
158
|
-
|
|
159
|
-
async getLabels() {
|
|
160
|
-
const tenantId = getTenantId()
|
|
161
|
-
if (!tenantId) return []
|
|
162
|
-
const { data } = await getClient()
|
|
163
|
-
.from(T.labels)
|
|
164
|
-
.select('*')
|
|
165
|
-
.eq('tenant_id', tenantId)
|
|
166
|
-
.eq('is_active', true)
|
|
167
|
-
.order('name')
|
|
168
|
-
return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as TaskLabel)
|
|
169
|
-
},
|
|
170
|
-
|
|
171
|
-
async getSummary() {
|
|
172
|
-
const tenantId = getTenantId()
|
|
173
|
-
if (!tenantId) return { total: 0, todo: 0, inProgress: 0, done: 0, overdue: 0, dueToday: 0, highPriority: 0 }
|
|
174
|
-
|
|
175
|
-
const { data } = await getClient()
|
|
176
|
-
.from(T.tasks)
|
|
177
|
-
.select('status, priority, due_date')
|
|
178
|
-
.eq('tenant_id', tenantId)
|
|
179
|
-
.is('parent_id', null)
|
|
180
|
-
|
|
181
|
-
const tasks = data ?? []
|
|
182
|
-
const today = new Date().toISOString().slice(0, 10)
|
|
183
|
-
return {
|
|
184
|
-
total: tasks.length,
|
|
185
|
-
todo: tasks.filter((t: any) => t.status === 'todo').length,
|
|
186
|
-
inProgress: tasks.filter((t: any) => t.status === 'in_progress').length,
|
|
187
|
-
done: tasks.filter((t: any) => t.status === 'done').length,
|
|
188
|
-
overdue: tasks.filter((t: any) => t.due_date && t.due_date < today && t.status !== 'done' && t.status !== 'cancelled').length,
|
|
189
|
-
dueToday: tasks.filter((t: any) => t.due_date === today && t.status !== 'done' && t.status !== 'cancelled').length,
|
|
190
|
-
highPriority: tasks.filter((t: any) => (t.priority === 'high' || t.priority === 'urgent') && t.status !== 'done' && t.status !== 'cancelled').length,
|
|
191
|
-
}
|
|
192
|
-
},
|
|
193
|
-
}
|
|
194
|
-
}
|
package/src/data/tables.ts
DELETED
package/src/data/types.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
Task, TaskLabel, CreateTaskInput, UpdateTaskInput,
|
|
3
|
-
TaskQuery, TasksSummary, TaskStatus,
|
|
4
|
-
} from '../types'
|
|
5
|
-
|
|
6
|
-
export interface TasksDataProvider {
|
|
7
|
-
getTasks(query: TaskQuery): Promise<Task[]>
|
|
8
|
-
getTaskById(id: string): Promise<Task | null>
|
|
9
|
-
getSubtasks(parentId: string): Promise<Task[]>
|
|
10
|
-
createTask(input: CreateTaskInput): Promise<Task>
|
|
11
|
-
updateTask(id: string, input: UpdateTaskInput): Promise<Task>
|
|
12
|
-
deleteTask(id: string): Promise<void>
|
|
13
|
-
reorderTask(id: string, newPosition: number, status: TaskStatus): Promise<void>
|
|
14
|
-
getLabels(): Promise<TaskLabel[]>
|
|
15
|
-
getSummary(): Promise<TasksSummary>
|
|
16
|
-
}
|