@fayz-ai/plugin-tasks 0.1.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.
Files changed (75) hide show
  1. package/dist/TasksContext.d.ts +20 -0
  2. package/dist/TasksContext.d.ts.map +1 -0
  3. package/dist/components/TaskCard.d.ts +10 -0
  4. package/dist/components/TaskCard.d.ts.map +1 -0
  5. package/dist/components/TaskDetail.d.ts +5 -0
  6. package/dist/components/TaskDetail.d.ts.map +1 -0
  7. package/dist/components/TaskLabelBadge.d.ts +9 -0
  8. package/dist/components/TaskLabelBadge.d.ts.map +1 -0
  9. package/dist/components/TaskPriorityBadge.d.ts +7 -0
  10. package/dist/components/TaskPriorityBadge.d.ts.map +1 -0
  11. package/dist/components/TaskQuickAdd.d.ts +5 -0
  12. package/dist/components/TaskQuickAdd.d.ts.map +1 -0
  13. package/dist/components/TaskStatusGroup.d.ts +7 -0
  14. package/dist/components/TaskStatusGroup.d.ts.map +1 -0
  15. package/dist/components/TasksDrawer.d.ts +3 -0
  16. package/dist/components/TasksDrawer.d.ts.map +1 -0
  17. package/dist/components/TasksFilterBar.d.ts +3 -0
  18. package/dist/components/TasksFilterBar.d.ts.map +1 -0
  19. package/dist/components/TasksGeneralSettings.d.ts +3 -0
  20. package/dist/components/TasksGeneralSettings.d.ts.map +1 -0
  21. package/dist/components/TasksTopbarButton.d.ts +15 -0
  22. package/dist/components/TasksTopbarButton.d.ts.map +1 -0
  23. package/dist/data/capability.test.d.ts +2 -0
  24. package/dist/data/capability.test.d.ts.map +1 -0
  25. package/dist/data/mock.d.ts +3 -0
  26. package/dist/data/mock.d.ts.map +1 -0
  27. package/dist/data/supabase.d.ts +3 -0
  28. package/dist/data/supabase.d.ts.map +1 -0
  29. package/dist/data/types.d.ts +13 -0
  30. package/dist/data/types.d.ts.map +1 -0
  31. package/dist/index.cjs +1108 -0
  32. package/dist/index.cjs.map +1 -0
  33. package/dist/index.d.ts +21 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +1102 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/lib/tenant.d.ts +3 -0
  38. package/dist/lib/tenant.d.ts.map +1 -0
  39. package/dist/locales/en.d.ts +2 -0
  40. package/dist/locales/en.d.ts.map +1 -0
  41. package/dist/locales/index.d.ts +2 -0
  42. package/dist/locales/index.d.ts.map +1 -0
  43. package/dist/locales/pt-BR.d.ts +2 -0
  44. package/dist/locales/pt-BR.d.ts.map +1 -0
  45. package/dist/registries.d.ts +3 -0
  46. package/dist/registries.d.ts.map +1 -0
  47. package/dist/store.d.ts +27 -0
  48. package/dist/store.d.ts.map +1 -0
  49. package/dist/types.d.ts +68 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/package.json +55 -0
  52. package/src/TasksContext.tsx +24 -0
  53. package/src/components/TaskCard.tsx +81 -0
  54. package/src/components/TaskDetail.tsx +184 -0
  55. package/src/components/TaskLabelBadge.tsx +37 -0
  56. package/src/components/TaskPriorityBadge.tsx +21 -0
  57. package/src/components/TaskQuickAdd.tsx +42 -0
  58. package/src/components/TaskStatusGroup.tsx +44 -0
  59. package/src/components/TasksDrawer.tsx +94 -0
  60. package/src/components/TasksFilterBar.tsx +57 -0
  61. package/src/components/TasksGeneralSettings.tsx +29 -0
  62. package/src/components/TasksTopbarButton.tsx +64 -0
  63. package/src/data/capability.test.ts +43 -0
  64. package/src/data/mock.ts +143 -0
  65. package/src/data/supabase.ts +193 -0
  66. package/src/data/types.ts +16 -0
  67. package/src/index.ts +217 -0
  68. package/src/lib/tenant.ts +13 -0
  69. package/src/locales/en.ts +40 -0
  70. package/src/locales/index.ts +7 -0
  71. package/src/locales/pt-BR.ts +40 -0
  72. package/src/migrations/001_tasks_base.sql +57 -0
  73. package/src/registries.ts +32 -0
  74. package/src/store.ts +148 -0
  75. package/src/types.ts +94 -0
@@ -0,0 +1,184 @@
1
+ import React, { useState, useEffect, useCallback } from 'react'
2
+ import { ArrowLeft, Trash2, Calendar, ChevronDown } from 'lucide-react'
3
+ import { useTasksStore } from '../TasksContext'
4
+ import { useTranslation } from '@fayz-ai/core'
5
+ import { TaskPriorityBadge } from './TaskPriorityBadge'
6
+ import { TaskLabelBadges } from './TaskLabelBadge'
7
+ import { TaskQuickAdd } from './TaskQuickAdd'
8
+ import { TaskCard } from './TaskCard'
9
+ import type { Task, TaskStatus, TaskPriority } from '../types'
10
+ import { TASK_STATUSES, TASK_PRIORITIES } from '../types'
11
+
12
+ const STATUS_DOT: Record<TaskStatus, string> = {
13
+ todo: 'bg-muted-foreground',
14
+ in_progress: 'bg-info',
15
+ done: 'bg-success',
16
+ cancelled: 'bg-muted-foreground/60',
17
+ }
18
+
19
+ function formatDate(iso: string): string {
20
+ return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
21
+ }
22
+
23
+ export function TaskDetail({ taskId }: { taskId: string }) {
24
+ const t = useTranslation()
25
+ const tasks = useTasksStore((s) => s.tasks)
26
+ const selectTask = useTasksStore((s) => s.selectTask)
27
+ const updateTask = useTasksStore((s) => s.updateTask)
28
+ const deleteTask = useTasksStore((s) => s.deleteTask)
29
+
30
+ const task = tasks.find((tt) => tt.id === taskId)
31
+
32
+ const [title, setTitle] = useState('')
33
+ const [description, setDescription] = useState('')
34
+ const [subtasks, setSubtasks] = useState<Task[]>([])
35
+
36
+ useEffect(() => {
37
+ if (task) {
38
+ setTitle(task.title)
39
+ setDescription(task.description ?? '')
40
+ }
41
+ }, [task])
42
+
43
+ useEffect(() => { setSubtasks([]) }, [taskId])
44
+
45
+ const handleTitleBlur = useCallback(() => {
46
+ if (task && title.trim() && title !== task.title) {
47
+ updateTask(task.id, { title: title.trim() })
48
+ }
49
+ }, [task, title, updateTask])
50
+
51
+ const handleDescriptionBlur = useCallback(() => {
52
+ if (task && description !== (task.description ?? '')) {
53
+ updateTask(task.id, { description: description || null })
54
+ }
55
+ }, [task, description, updateTask])
56
+
57
+ const handleDelete = useCallback(() => {
58
+ if (task && confirm(t('tasks.detail.deleteConfirm'))) {
59
+ deleteTask(task.id)
60
+ }
61
+ }, [task, deleteTask, t])
62
+
63
+ if (!task) {
64
+ return (
65
+ <div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
66
+ <p className="text-sm">Task not found</p>
67
+ <button type="button" onClick={() => selectTask(null)} className="mt-2 text-xs text-primary hover:underline">
68
+ {t('tasks.detail.back')}
69
+ </button>
70
+ </div>
71
+ )
72
+ }
73
+
74
+ return (
75
+ <div className="space-y-3">
76
+ {/* Back + title row */}
77
+ <div className="flex items-center gap-2">
78
+ <button
79
+ type="button"
80
+ onClick={() => selectTask(null)}
81
+ className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
82
+ >
83
+ <ArrowLeft className="h-4 w-4" />
84
+ </button>
85
+ <input
86
+ type="text"
87
+ value={title}
88
+ onChange={(e) => setTitle(e.target.value)}
89
+ onBlur={handleTitleBlur}
90
+ onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }}
91
+ className="flex-1 bg-transparent text-sm font-semibold outline-none"
92
+ />
93
+ </div>
94
+
95
+ {/* Inline property chips */}
96
+ <div className="flex flex-wrap items-center gap-1.5">
97
+ {/* Status chip */}
98
+ <div className="relative">
99
+ <select
100
+ value={task.status}
101
+ onChange={(e) => updateTask(task.id, { status: e.target.value as TaskStatus })}
102
+ className="appearance-none rounded-full border bg-background pl-5 pr-6 py-1 text-xs font-medium cursor-pointer hover:border-primary/40 transition-colors"
103
+ >
104
+ {TASK_STATUSES.map((s) => (
105
+ <option key={s} value={s}>{t(`tasks.status.${s}`)}</option>
106
+ ))}
107
+ </select>
108
+ <span className={`absolute left-2 top-1/2 -translate-y-1/2 h-2 w-2 rounded-full ${STATUS_DOT[task.status]}`} />
109
+ <ChevronDown className="absolute right-1.5 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground pointer-events-none" />
110
+ </div>
111
+
112
+ {/* Priority chip */}
113
+ <div className="relative">
114
+ <select
115
+ value={task.priority}
116
+ onChange={(e) => updateTask(task.id, { priority: e.target.value as TaskPriority })}
117
+ className="appearance-none rounded-full border bg-background pl-2 pr-6 py-1 text-xs font-medium cursor-pointer hover:border-primary/40 transition-colors"
118
+ >
119
+ {TASK_PRIORITIES.map((p) => (
120
+ <option key={p} value={p}>{t(`tasks.priority.${p}`)}</option>
121
+ ))}
122
+ </select>
123
+ <ChevronDown className="absolute right-1.5 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground pointer-events-none" />
124
+ </div>
125
+
126
+ {/* Due date chip */}
127
+ <div className="relative">
128
+ <input
129
+ type="date"
130
+ value={task.dueDate ?? ''}
131
+ onChange={(e) => updateTask(task.id, { dueDate: e.target.value || null })}
132
+ className="appearance-none rounded-full border bg-background pl-6 pr-2 py-1 text-xs font-medium cursor-pointer hover:border-primary/40 transition-colors w-[120px]"
133
+ />
134
+ <Calendar className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground pointer-events-none" />
135
+ </div>
136
+
137
+ {/* Labels */}
138
+ {task.labels.length > 0 && <TaskLabelBadges labelIds={task.labels} max={3} />}
139
+ </div>
140
+
141
+ {/* Description */}
142
+ <textarea
143
+ value={description}
144
+ onChange={(e) => setDescription(e.target.value)}
145
+ onBlur={handleDescriptionBlur}
146
+ placeholder={t('tasks.detail.descriptionPlaceholder')}
147
+ rows={3}
148
+ className="w-full rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-3 py-2 text-xs outline-none resize-none placeholder:text-muted-foreground/50 leading-relaxed"
149
+ />
150
+
151
+ {/* Subtasks */}
152
+ <div>
153
+ <p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-1">
154
+ {t('tasks.detail.subtasks')}
155
+ </p>
156
+ <div className="rounded-md border overflow-hidden">
157
+ <TaskQuickAdd parentId={task.id} />
158
+ {subtasks.length > 0 && (
159
+ <div className="divide-y">
160
+ {subtasks.map((st) => (
161
+ <TaskCard key={st.id} task={st} />
162
+ ))}
163
+ </div>
164
+ )}
165
+ </div>
166
+ </div>
167
+
168
+ {/* Footer: timestamps + delete */}
169
+ <div className="flex items-center justify-between pt-2 border-t">
170
+ <span className="text-[10px] text-muted-foreground">
171
+ {formatDate(task.createdAt)} · {t('tasks.detail.updated')} {formatDate(task.updatedAt)}
172
+ </span>
173
+ <button
174
+ type="button"
175
+ onClick={handleDelete}
176
+ className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-destructive transition-colors"
177
+ >
178
+ <Trash2 className="h-3 w-3" />
179
+ {t('tasks.detail.delete')}
180
+ </button>
181
+ </div>
182
+ </div>
183
+ )
184
+ }
@@ -0,0 +1,37 @@
1
+ import React from 'react'
2
+ import { useTasksStore } from '../TasksContext'
3
+
4
+ export function TaskLabelBadge({ labelId }: { labelId: string }) {
5
+ const labels = useTasksStore((s) => s.labels)
6
+ const label = labels.find((l) => l.id === labelId)
7
+ if (!label) return null
8
+
9
+ return (
10
+ <span
11
+ className="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium leading-none"
12
+ style={{
13
+ backgroundColor: label.color + '20',
14
+ color: label.color,
15
+ }}
16
+ >
17
+ {label.name}
18
+ </span>
19
+ )
20
+ }
21
+
22
+ export function TaskLabelBadges({ labelIds, max = 2 }: { labelIds: string[]; max?: number }) {
23
+ if (labelIds.length === 0) return null
24
+ const visible = labelIds.slice(0, max)
25
+ const overflow = labelIds.length - max
26
+
27
+ return (
28
+ <span className="inline-flex items-center gap-1">
29
+ {visible.map((id) => (
30
+ <TaskLabelBadge key={id} labelId={id} />
31
+ ))}
32
+ {overflow > 0 && (
33
+ <span className="text-[10px] text-muted-foreground">+{overflow}</span>
34
+ )}
35
+ </span>
36
+ )
37
+ }
@@ -0,0 +1,21 @@
1
+ import React from 'react'
2
+ import { AlertTriangle, ArrowUp, ArrowDown, Minus } from 'lucide-react'
3
+ import type { TaskPriority } from '../types'
4
+
5
+ const PRIORITY_CONFIG: Record<TaskPriority, { color: string; bg: string; icon: React.ComponentType<{ className?: string }> }> = {
6
+ low: { color: 'text-muted-foreground', bg: 'bg-muted', icon: ArrowDown },
7
+ medium: { color: 'text-info-soft-foreground', bg: 'bg-info-soft', icon: Minus },
8
+ high: { color: 'text-warning-soft-foreground', bg: 'bg-warning-soft', icon: ArrowUp },
9
+ urgent: { color: 'text-destructive-soft-foreground', bg: 'bg-destructive-soft', icon: AlertTriangle },
10
+ }
11
+
12
+ export function TaskPriorityBadge({ priority, showLabel }: { priority: TaskPriority; showLabel?: boolean }) {
13
+ const cfg = PRIORITY_CONFIG[priority]
14
+ const Icon = cfg.icon
15
+ return (
16
+ <span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium ${cfg.color} ${cfg.bg}`}>
17
+ <Icon className="h-3 w-3" />
18
+ {showLabel && <span className="capitalize">{priority}</span>}
19
+ </span>
20
+ )
21
+ }
@@ -0,0 +1,42 @@
1
+ import React, { useState, useCallback } from 'react'
2
+ import { Plus } from 'lucide-react'
3
+ import { useTasksStore, useTasksConfig } from '../TasksContext'
4
+ import { useTranslation } from '@fayz-ai/core'
5
+ import type { TaskPriority } from '../types'
6
+
7
+ export function TaskQuickAdd({ parentId }: { parentId?: string | null }) {
8
+ const t = useTranslation()
9
+ const createTask = useTasksStore((s) => s.createTask)
10
+ const [title, setTitle] = useState('')
11
+ const [loading, setLoading] = useState(false)
12
+
13
+ const handleSubmit = useCallback(async (e: React.FormEvent) => {
14
+ e.preventDefault()
15
+ const trimmed = title.trim()
16
+ if (!trimmed || loading) return
17
+ setLoading(true)
18
+ try {
19
+ await createTask({
20
+ title: trimmed,
21
+ parentId: parentId ?? null,
22
+ })
23
+ setTitle('')
24
+ } finally {
25
+ setLoading(false)
26
+ }
27
+ }, [title, loading, createTask, parentId])
28
+
29
+ return (
30
+ <form onSubmit={handleSubmit} className="flex items-center gap-2 px-3 py-2 border-b">
31
+ <Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
32
+ <input
33
+ type="text"
34
+ value={title}
35
+ onChange={(e) => setTitle(e.target.value)}
36
+ placeholder={parentId ? t('tasks.detail.addSubtask') : t('tasks.quickAdd.placeholder')}
37
+ className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60"
38
+ disabled={loading}
39
+ />
40
+ </form>
41
+ )
42
+ }
@@ -0,0 +1,44 @@
1
+ import React, { useState } from 'react'
2
+ import { ChevronDown, ChevronRight } from 'lucide-react'
3
+ import { useTranslation } from '@fayz-ai/core'
4
+ import { TaskCard } from './TaskCard'
5
+ import type { Task, TaskStatus } from '../types'
6
+
7
+ const STATUS_COLORS: Record<TaskStatus, string> = {
8
+ todo: 'bg-muted-foreground',
9
+ in_progress: 'bg-info',
10
+ done: 'bg-success',
11
+ cancelled: 'bg-muted-foreground/60',
12
+ }
13
+
14
+ export function TaskStatusGroup({ status, tasks }: { status: TaskStatus; tasks: Task[] }) {
15
+ const t = useTranslation()
16
+ const [collapsed, setCollapsed] = useState(status === 'cancelled')
17
+
18
+ if (tasks.length === 0) return null
19
+
20
+ const Chevron = collapsed ? ChevronRight : ChevronDown
21
+
22
+ return (
23
+ <div className="space-y-0.5">
24
+ <button
25
+ type="button"
26
+ onClick={() => setCollapsed(!collapsed)}
27
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
28
+ >
29
+ <span className={`h-2 w-2 rounded-full ${STATUS_COLORS[status]}`} />
30
+ <span>{t(`tasks.status.${status}`)}</span>
31
+ <span className="text-[10px] font-normal">({tasks.length})</span>
32
+ <Chevron className="ml-auto h-3.5 w-3.5" />
33
+ </button>
34
+
35
+ {!collapsed && (
36
+ <div>
37
+ {tasks.map((task) => (
38
+ <TaskCard key={task.id} task={task} />
39
+ ))}
40
+ </div>
41
+ )}
42
+ </div>
43
+ )
44
+ }
@@ -0,0 +1,94 @@
1
+ import React from 'react'
2
+ import {
3
+ Sheet, SheetContent, SheetHeader, SheetBody, SheetTitle, SheetDescription,
4
+ } from '@fayz-ai/ui'
5
+ import { useTasksStore } from '../TasksContext'
6
+ import { useTranslation } from '@fayz-ai/core'
7
+ import { TasksFilterBar } from './TasksFilterBar'
8
+ import { TaskQuickAdd } from './TaskQuickAdd'
9
+ import { TaskCard } from './TaskCard'
10
+ import { TaskStatusGroup } from './TaskStatusGroup'
11
+ import { TaskDetail } from './TaskDetail'
12
+ import { ClipboardList } from 'lucide-react'
13
+ import type { TaskStatus } from '../types'
14
+
15
+ const STATUS_ORDER: TaskStatus[] = ['todo', 'in_progress', 'done', 'cancelled']
16
+
17
+ export function TasksDrawer() {
18
+ const t = useTranslation()
19
+ const drawerOpen = useTasksStore((s) => s.drawerOpen)
20
+ const closeDrawer = useTasksStore((s) => s.closeDrawer)
21
+ const selectedTaskId = useTasksStore((s) => s.selectedTaskId)
22
+ const tasks = useTasksStore((s) => s.tasks)
23
+ const tasksLoading = useTasksStore((s) => s.tasksLoading)
24
+ const summary = useTasksStore((s) => s.summary)
25
+ const activeFilter = useTasksStore((s) => s.activeFilter)
26
+
27
+ // Group tasks by status
28
+ const grouped = React.useMemo(() => {
29
+ const map = new Map<TaskStatus, typeof tasks>()
30
+ for (const s of STATUS_ORDER) map.set(s, [])
31
+ for (const task of tasks) {
32
+ const group = map.get(task.status)
33
+ if (group) group.push(task)
34
+ }
35
+ return map
36
+ }, [tasks])
37
+
38
+ // Summary line
39
+ const summaryLine = React.useMemo(() => {
40
+ if (!summary) return ''
41
+ const parts: string[] = []
42
+ if (summary.overdue > 0) parts.push(`${summary.overdue} ${t('tasks.summary.overdue')}`)
43
+ if (summary.dueToday > 0) parts.push(`${summary.dueToday} ${t('tasks.summary.dueToday')}`)
44
+ return parts.join(' · ')
45
+ }, [summary, t])
46
+
47
+ return (
48
+ <Sheet open={drawerOpen} onOpenChange={(v) => { if (!v) closeDrawer() }}>
49
+ <SheetContent width="max-w-lg" overlay="dim">
50
+ <SheetHeader>
51
+ <SheetTitle>{t('tasks.drawer.title')}</SheetTitle>
52
+ <SheetDescription>
53
+ {summaryLine || t('tasks.drawer.subtitle')}
54
+ </SheetDescription>
55
+ </SheetHeader>
56
+
57
+ {!selectedTaskId && <TasksFilterBar />}
58
+ {!selectedTaskId && <TaskQuickAdd />}
59
+
60
+ <SheetBody className="px-0 py-0">
61
+ {selectedTaskId ? (
62
+ <div className="px-5 py-4">
63
+ <TaskDetail taskId={selectedTaskId} />
64
+ </div>
65
+ ) : tasksLoading && tasks.length === 0 ? (
66
+ <div className="flex items-center justify-center py-16">
67
+ <div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
68
+ </div>
69
+ ) : tasks.length === 0 ? (
70
+ <div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
71
+ <ClipboardList className="h-10 w-10 mb-3 opacity-40" />
72
+ <p className="text-sm font-medium">{t('tasks.empty')}</p>
73
+ <p className="text-xs mt-1">{t('tasks.empty.description')}</p>
74
+ </div>
75
+ ) : activeFilter !== 'all' ? (
76
+ <div className="py-1">
77
+ {tasks.map((task) => (
78
+ <div key={task.id} className="px-2">
79
+ <TaskCard task={task} />
80
+ </div>
81
+ ))}
82
+ </div>
83
+ ) : (
84
+ <div className="py-2 space-y-2">
85
+ {STATUS_ORDER.filter((s) => s !== 'cancelled' || (grouped.get(s)?.length ?? 0) > 0).map((status) => (
86
+ <TaskStatusGroup key={status} status={status} tasks={grouped.get(status) ?? []} />
87
+ ))}
88
+ </div>
89
+ )}
90
+ </SheetBody>
91
+ </SheetContent>
92
+ </Sheet>
93
+ )
94
+ }
@@ -0,0 +1,57 @@
1
+ import React from 'react'
2
+ import { useTasksStore } from '../TasksContext'
3
+ import { useTranslation } from '@fayz-ai/core'
4
+ import type { TaskStatus } from '../types'
5
+
6
+ const FILTERS: Array<{ key: TaskStatus | 'all'; labelKey: string }> = [
7
+ { key: 'all', labelKey: 'tasks.filter.all' },
8
+ { key: 'todo', labelKey: 'tasks.filter.todo' },
9
+ { key: 'in_progress', labelKey: 'tasks.filter.inProgress' },
10
+ { key: 'done', labelKey: 'tasks.filter.done' },
11
+ ]
12
+
13
+ export function TasksFilterBar() {
14
+ const t = useTranslation()
15
+ const activeFilter = useTasksStore((s) => s.activeFilter)
16
+ const setFilter = useTasksStore((s) => s.setFilter)
17
+ const summary = useTasksStore((s) => s.summary)
18
+
19
+ const getCount = (key: TaskStatus | 'all'): number | undefined => {
20
+ if (!summary) return undefined
21
+ switch (key) {
22
+ case 'all': return summary.total
23
+ case 'todo': return summary.todo
24
+ case 'in_progress': return summary.inProgress
25
+ case 'done': return summary.done
26
+ default: return undefined
27
+ }
28
+ }
29
+
30
+ return (
31
+ <div className="flex items-center gap-1 px-3 py-2 border-b overflow-x-auto">
32
+ {FILTERS.map(({ key, labelKey }) => {
33
+ const active = activeFilter === key
34
+ const count = getCount(key)
35
+ return (
36
+ <button
37
+ key={key}
38
+ type="button"
39
+ onClick={() => setFilter(key)}
40
+ className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors whitespace-nowrap ${
41
+ active
42
+ ? 'bg-primary text-primary-foreground'
43
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground'
44
+ }`}
45
+ >
46
+ {t(labelKey)}
47
+ {count != null && (
48
+ <span className={`text-[10px] ${active ? 'text-primary-foreground/80' : 'text-muted-foreground'}`}>
49
+ {count}
50
+ </span>
51
+ )}
52
+ </button>
53
+ )
54
+ })}
55
+ </div>
56
+ )
57
+ }
@@ -0,0 +1,29 @@
1
+ import React from 'react'
2
+ import { useTranslation } from '@fayz-ai/core'
3
+
4
+ export function TasksGeneralSettings() {
5
+ const t = useTranslation()
6
+
7
+ return (
8
+ <div className="space-y-6">
9
+ <div>
10
+ <h3 className="text-sm font-semibold mb-1">{t('tasks.settings.general')}</h3>
11
+ <p className="text-xs text-muted-foreground mb-3">
12
+ {t('tasks.drawer.subtitle')}
13
+ </p>
14
+ </div>
15
+
16
+ <div className="space-y-4">
17
+ <div className="space-y-1.5">
18
+ <label className="text-sm font-medium">{t('tasks.settings.defaultPriority')}</label>
19
+ <select className="w-full max-w-xs rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-3 py-2 text-sm">
20
+ <option value="low">{t('tasks.priority.low')}</option>
21
+ <option value="medium" selected>{t('tasks.priority.medium')}</option>
22
+ <option value="high">{t('tasks.priority.high')}</option>
23
+ <option value="urgent">{t('tasks.priority.urgent')}</option>
24
+ </select>
25
+ </div>
26
+ </div>
27
+ </div>
28
+ )
29
+ }
@@ -0,0 +1,64 @@
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 { TasksContextProvider, type ResolvedTasksConfig } from '../TasksContext'
6
+ import { TasksDrawer } from './TasksDrawer'
7
+ import type { TasksDataProvider } from '../data/types'
8
+ import type { TasksUIState } from '../store'
9
+ import { useStore, type StoreApi } from 'zustand'
10
+
11
+ interface TasksTopbarWidgetProps {
12
+ config: {
13
+ tasksConfig: ResolvedTasksConfig
14
+ tasksProvider: TasksDataProvider
15
+ tasksStore: StoreApi<TasksUIState>
16
+ }
17
+ }
18
+
19
+ export function TasksTopbarButton({ config }: TasksTopbarWidgetProps) {
20
+ const { tasksConfig, tasksProvider, tasksStore } = config
21
+
22
+ return (
23
+ <TasksContextProvider config={tasksConfig} provider={tasksProvider} store={tasksStore}>
24
+ <TasksTopbarButtonInner store={tasksStore} />
25
+ <TasksDrawer />
26
+ </TasksContextProvider>
27
+ )
28
+ }
29
+
30
+ function TasksTopbarButtonInner({ store }: { store: StoreApi<TasksUIState> }) {
31
+ const toggleDrawer = useStore(store, (s) => s.toggleDrawer)
32
+ const fetchSummary = useStore(store, (s) => s.fetchSummary)
33
+ const summary = useStore(store, (s) => s.summary)
34
+
35
+ useEffect(() => {
36
+ fetchSummary()
37
+ }, [fetchSummary])
38
+
39
+ const pendingCount = summary ? summary.todo + summary.inProgress : 0
40
+
41
+ return (
42
+ <Button
43
+ variant="ghost"
44
+ size="icon"
45
+ className={cn('relative text-sidebar-foreground/70 hover:bg-sidebar/30 hover:text-sidebar-foreground')}
46
+ onClick={toggleDrawer}
47
+ aria-label="Tasks"
48
+ >
49
+ <CheckSquare className="h-5 w-5" />
50
+ {pendingCount > 0 && (
51
+ <span
52
+ className={cn(
53
+ 'absolute -right-0.5 -top-0.5 flex items-center justify-center rounded-full bg-destructive text-destructive-foreground',
54
+ pendingCount > 9
55
+ ? 'h-5 min-w-5 px-1 text-[10px]'
56
+ : 'h-4 w-4 text-[10px]'
57
+ )}
58
+ >
59
+ {pendingCount > 99 ? '99+' : pendingCount}
60
+ </span>
61
+ )}
62
+ </Button>
63
+ )
64
+ }
@@ -0,0 +1,43 @@
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 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
+ })