feat(KB-055): merge feat-github-int into main
This commit is contained in:
12
.changeset/add-ntfy-notifications.md
Normal file
12
.changeset/add-ntfy-notifications.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add ntfy.sh push notifications for task completion and failures
|
||||||
|
|
||||||
|
Configure your ntfy.sh topic in Settings → Notifications to receive push notifications when:
|
||||||
|
- Tasks complete and move to "in-review"
|
||||||
|
- Tasks are merged to main (move to "done")
|
||||||
|
- Tasks fail and need attention
|
||||||
|
|
||||||
|
The ntfy topic is also included in task prompts when configured, making it easy for agents to send custom notifications during task execution.
|
||||||
5
.changeset/hide-done-tasks-list-view.md
Normal file
5
.changeset/hide-done-tasks-list-view.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add ability to hide done tasks in list view. The toggle persists in localStorage and helps declutter the view when many tasks are completed.
|
||||||
29
AGENTS.md
29
AGENTS.md
@@ -121,6 +121,35 @@ To enable manual plan approval:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `ntfyEnabled` (default: `false`)
|
||||||
|
|
||||||
|
When true, enables ntfy.sh push notifications for task completion and failures.
|
||||||
|
|
||||||
|
**Notification events:**
|
||||||
|
- Task moves to "in-review" — "Task completed — ready for review"
|
||||||
|
- Task moves to "done" — "Task merged to main"
|
||||||
|
- Task status becomes "failed" — "Task failed" (high priority)
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
1. Go to https://ntfy.sh and pick a unique topic name (or self-host ntfy)
|
||||||
|
2. Open dashboard Settings → Notifications
|
||||||
|
3. Enable notifications and enter your topic name
|
||||||
|
4. Install the ntfy app on your phone/desktop and subscribe to your topic
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"ntfyEnabled": true,
|
||||||
|
"ntfyTopic": "my-kb-notifications"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- No authentication required for public topics (keep it simple)
|
||||||
|
- Topic must be 1–64 alphanumeric/hyphen/underscore characters
|
||||||
|
- Notifications are best-effort: failures are logged but don't block task execution
|
||||||
|
|
||||||
## Per-Task Model Overrides
|
## Per-Task Model Overrides
|
||||||
|
|
||||||
The kb dashboard allows overriding the global AI model selection on a per-task basis. This enables using different models for different types of work without changing global settings.
|
The kb dashboard allows overriding the global AI model selection on a per-task basis. This enables using different models for different types of work without changing global settings.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { exec } from "node:child_process";
|
|||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
import { TaskStore } from "@kb/core";
|
import { TaskStore } from "@kb/core";
|
||||||
import { createServer } from "@kb/dashboard";
|
import { createServer } from "@kb/dashboard";
|
||||||
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees } from "@kb/engine";
|
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees, NtfyNotifier } from "@kb/engine";
|
||||||
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
function openBrowser(url: string): void {
|
function openBrowser(url: string): void {
|
||||||
@@ -19,6 +19,10 @@ export async function runDashboard(port: number, opts: { open?: boolean; paused?
|
|||||||
await store.init();
|
await store.init();
|
||||||
await store.watch();
|
await store.watch();
|
||||||
|
|
||||||
|
// ── NtfyNotifier: push notifications for task completion and failures ─
|
||||||
|
const notifier = new NtfyNotifier(store);
|
||||||
|
notifier.start();
|
||||||
|
|
||||||
// Set enginePaused if starting in paused mode
|
// Set enginePaused if starting in paused mode
|
||||||
if (opts.paused) {
|
if (opts.paused) {
|
||||||
await store.updateSettings({ enginePaused: true });
|
await store.updateSettings({ enginePaused: true });
|
||||||
@@ -375,6 +379,7 @@ export async function runDashboard(port: number, opts: { open?: boolean; paused?
|
|||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
triage.stop();
|
triage.stop();
|
||||||
scheduler.stop();
|
scheduler.stop();
|
||||||
|
notifier.stop();
|
||||||
if (mergeRetryTimer) clearTimeout(mergeRetryTimer);
|
if (mergeRetryTimer) clearTimeout(mergeRetryTimer);
|
||||||
store.stopWatching();
|
store.stopWatching();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -384,6 +389,7 @@ export async function runDashboard(port: number, opts: { open?: boolean; paused?
|
|||||||
// Dev mode: simplified SIGINT handler (no engine components)
|
// Dev mode: simplified SIGINT handler (no engine components)
|
||||||
if (opts.dev) {
|
if (opts.dev) {
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
|
notifier.stop();
|
||||||
store.stopWatching();
|
store.stopWatching();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { EventEmitter } from "node:events";
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { appendFile, mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises";
|
import { appendFile, mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises";
|
||||||
import { join, sep } from "node:path";
|
import { join, sep } from "node:path";
|
||||||
import { existsSync, watch, type FSWatcher } from "node:fs";
|
import { existsSync, watch, type FSWatcher, readFileSync } from "node:fs";
|
||||||
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings } from "./types.js";
|
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings } from "./types.js";
|
||||||
import { VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
|
import { VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
|
||||||
|
|
||||||
@@ -1174,6 +1174,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
? task.dependencies.map((d) => `- **Task:** ${d}`).join("\n")
|
? task.dependencies.map((d) => `- **Task:** ${d}`).join("\n")
|
||||||
: "- **None**";
|
: "- **None**";
|
||||||
|
|
||||||
|
// Get current settings to check for ntfy configuration
|
||||||
|
const settings = this.getSettingsSync();
|
||||||
|
const notificationsSection =
|
||||||
|
settings.ntfyEnabled && settings.ntfyTopic
|
||||||
|
? `\n## Notifications\n\nntfy topic: \`${settings.ntfyTopic}\`\n`
|
||||||
|
: "";
|
||||||
|
|
||||||
const heading = task.title ? `${task.id}: ${task.title}` : task.id;
|
const heading = task.title ? `${task.id}: ${task.title}` : task.id;
|
||||||
return `# ${heading}
|
return `# ${heading}
|
||||||
|
|
||||||
@@ -1209,6 +1216,23 @@ ${deps}
|
|||||||
|
|
||||||
- [ ] All steps complete
|
- [ ] All steps complete
|
||||||
- [ ] All tests passing
|
- [ ] All tests passing
|
||||||
`;
|
${notificationsSection}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous version of getSettings for internal use.
|
||||||
|
* Returns cached settings or default settings if not loaded.
|
||||||
|
*/
|
||||||
|
private getSettingsSync(): Settings {
|
||||||
|
// Since we can't easily make generateSpecifiedPrompt async,
|
||||||
|
// we read settings synchronously from the file.
|
||||||
|
// The settings file is read during init and on each update,
|
||||||
|
// so this should be reasonably up-to-date for prompt generation.
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(readFileSync(this.configPath, "utf-8"));
|
||||||
|
return { ...DEFAULT_SETTINGS, ...config.settings };
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_SETTINGS;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,12 @@ export interface Settings {
|
|||||||
* remain in triage with status "awaiting-approval" until a user approves
|
* remain in triage with status "awaiting-approval" until a user approves
|
||||||
* or rejects the plan. Default: false. */
|
* or rejects the plan. Default: false. */
|
||||||
requirePlanApproval?: boolean;
|
requirePlanApproval?: boolean;
|
||||||
|
/** ntfy.sh topic name for push notifications. When set along with ntfyEnabled,
|
||||||
|
* notifications are sent to https://ntfy.sh/{topic} when tasks complete or fail. */
|
||||||
|
ntfyTopic?: string;
|
||||||
|
/** When true, enables ntfy.sh push notifications for task completion and failures.
|
||||||
|
* Requires ntfyTopic to be set. Default: false. */
|
||||||
|
ntfyEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
@@ -229,6 +235,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
autoResolveConflicts: true,
|
autoResolveConflicts: true,
|
||||||
smartConflictResolution: true,
|
smartConflictResolution: true,
|
||||||
requirePlanApproval: false,
|
requirePlanApproval: false,
|
||||||
|
ntfyEnabled: false,
|
||||||
|
ntfyTopic: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BoardConfig {
|
export interface BoardConfig {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Web-based dashboard for managing kb tasks. Provides a visual kanban board, list
|
|||||||
### Task Management
|
### Task Management
|
||||||
- **Kanban Board**: Drag-and-drop task management across columns (Triage, Todo, In Progress, In Review, Done)
|
- **Kanban Board**: Drag-and-drop task management across columns (Triage, Todo, In Progress, In Review, Done)
|
||||||
- **Inline Editing**: Quick-edit task title and description directly on the board for Triage and Todo columns. Double-click a card or use the pencil icon that appears on hover.
|
- **Inline Editing**: Quick-edit task title and description directly on the board for Triage and Todo columns. Double-click a card or use the pencil icon that appears on hover.
|
||||||
- **List View**: Alternative tabular view for tasks with sorting, filtering, and collapsible column sections. Click section headers to expand/collapse each column group. Section expansion state is persisted to localStorage.
|
- **List View**: Alternative tabular view for tasks with sorting and filtering
|
||||||
- **Task Details**: View full task specifications, agent logs, and attachments
|
- **Task Details**: View full task specifications, agent logs, and attachments
|
||||||
- **GitHub Import**: Import issues directly from GitHub repositories
|
- **GitHub Import**: Import issues directly from GitHub repositories
|
||||||
- **PR Management**: Create and track pull requests for in-review tasks
|
- **PR Management**: Create and track pull requests for in-review tasks
|
||||||
@@ -44,7 +44,8 @@ The Git Manager provides comprehensive repository visualization and management d
|
|||||||
- View operation results and error states
|
- View operation results and error states
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
- **Settings Modal**: Configure scheduling, worktrees, build commands, merge preferences
|
- **Settings Modal**: Configure scheduling, worktrees, build commands, merge preferences, and notifications
|
||||||
|
- **Notifications**: ntfy.sh integration for push notifications when tasks complete or fail
|
||||||
- **Authentication**: OAuth provider management for AI model access
|
- **Authentication**: OAuth provider management for AI model access
|
||||||
- **Pause Controls**: Soft pause (stop new work) and hard stop (kill all agents)
|
- **Pause Controls**: Soft pause (stop new work) and hard stop (kill all agents)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
|
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
|
||||||
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, ChevronRight } from "lucide-react";
|
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye } from "lucide-react";
|
||||||
import type { Task, TaskDetail, Column, TaskStep } from "@kb/core";
|
import type { Task, TaskDetail, Column, TaskStep } from "@kb/core";
|
||||||
import { COLUMN_LABELS, COLUMNS } from "@kb/core";
|
import { COLUMN_LABELS, COLUMNS } from "@kb/core";
|
||||||
import { fetchTaskDetail } from "../api";
|
import { fetchTaskDetail } from "../api";
|
||||||
|
import { InlineCreateCard } from "./InlineCreateCard";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
|
||||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||||
@@ -58,12 +59,16 @@ export function ListView({
|
|||||||
addToast,
|
addToast,
|
||||||
globalPaused,
|
globalPaused,
|
||||||
onNewTask,
|
onNewTask,
|
||||||
|
isCreating,
|
||||||
|
onCancelCreate,
|
||||||
|
onCreateTask,
|
||||||
}: ListViewProps) {
|
}: ListViewProps) {
|
||||||
const [sortField, setSortField] = useState<SortField>("createdAt");
|
const [sortField, setSortField] = useState<SortField>("createdAt");
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
|
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
||||||
const [dragOverColumn, setDragOverColumn] = useState<Column | null>(null);
|
const [dragOverColumn, setDragOverColumn] = useState<Column | null>(null);
|
||||||
|
const [selectedColumn, setSelectedColumn] = useState<Column | null>(null);
|
||||||
|
|
||||||
// Column visibility state - initialize from localStorage or default to all columns
|
// Column visibility state - initialize from localStorage or default to all columns
|
||||||
const [visibleColumns, setVisibleColumns] = useState<Set<ListColumn>>(() => {
|
const [visibleColumns, setVisibleColumns] = useState<Set<ListColumn>>(() => {
|
||||||
@@ -87,6 +92,21 @@ export function ListView({
|
|||||||
return new Set(ALL_LIST_COLUMNS);
|
return new Set(ALL_LIST_COLUMNS);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hide done tasks state - initialize from localStorage
|
||||||
|
const [hideDoneTasks, setHideDoneTasks] = useState<boolean>(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem("kb-dashboard-hide-done");
|
||||||
|
if (saved !== null) {
|
||||||
|
return saved === "true";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid localStorage data - fall through to default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false; // Default: show done tasks
|
||||||
|
});
|
||||||
|
|
||||||
// Persist column visibility changes to localStorage
|
// Persist column visibility changes to localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -94,61 +114,12 @@ export function ListView({
|
|||||||
}
|
}
|
||||||
}, [visibleColumns]);
|
}, [visibleColumns]);
|
||||||
|
|
||||||
/**
|
// Persist hide done tasks state to localStorage
|
||||||
* Section expansion state - tracks which column sections are expanded/collapsed.
|
|
||||||
* Initialized from localStorage with key "kb-dashboard-list-sections".
|
|
||||||
* Defaults to all sections expanded if no saved state exists.
|
|
||||||
* Persists changes to localStorage whenever sections are expanded/collapsed.
|
|
||||||
*/
|
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<Column>>(() => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem("kb-dashboard-list-sections");
|
|
||||||
if (saved) {
|
|
||||||
const parsed = JSON.parse(saved) as Column[];
|
|
||||||
// Validate that all saved values are valid Column values
|
|
||||||
const validColumns = parsed.filter((col): col is Column =>
|
|
||||||
COLUMNS.includes(col as Column)
|
|
||||||
);
|
|
||||||
return new Set(validColumns);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Invalid localStorage data - fall through to default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Default: all sections expanded
|
|
||||||
return new Set<Column>(COLUMNS);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Persist section expansion state to localStorage
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.setItem("kb-dashboard-list-sections", JSON.stringify([...expandedSections]));
|
localStorage.setItem("kb-dashboard-hide-done", hideDoneTasks.toString());
|
||||||
}
|
}
|
||||||
}, [expandedSections]);
|
}, [hideDoneTasks]);
|
||||||
|
|
||||||
// Toggle a section's expansion state
|
|
||||||
const toggleSection = useCallback((column: Column) => {
|
|
||||||
setExpandedSections((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(column)) {
|
|
||||||
next.delete(column);
|
|
||||||
} else {
|
|
||||||
next.add(column);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Expand all sections
|
|
||||||
const expandAll = useCallback(() => {
|
|
||||||
setExpandedSections(new Set<Column>(COLUMNS));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Collapse all sections
|
|
||||||
const collapseAll = useCallback(() => {
|
|
||||||
setExpandedSections(new Set<Column>());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Column dropdown state
|
// Column dropdown state
|
||||||
const [columnDropdownOpen, setColumnDropdownOpen] = useState(false);
|
const [columnDropdownOpen, setColumnDropdownOpen] = useState(false);
|
||||||
@@ -216,17 +187,36 @@ export function ListView({
|
|||||||
}
|
}
|
||||||
}, [sortField]);
|
}, [sortField]);
|
||||||
|
|
||||||
|
const handleColumnFilter = useCallback((column: Column) => {
|
||||||
|
setSelectedColumn((prev) => (prev === column ? null : column));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearColumnFilter = useCallback(() => {
|
||||||
|
setSelectedColumn(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const groupedTasks = useMemo(() => {
|
const groupedTasks = useMemo(() => {
|
||||||
const filtered = filter
|
// First apply text filter
|
||||||
|
let filtered = filter
|
||||||
? tasks.filter(
|
? tasks.filter(
|
||||||
(t) =>
|
(t) =>
|
||||||
t.id.toLowerCase().includes(filter.toLowerCase()) ||
|
t.id.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
(t.title && t.title.toLowerCase().includes(filter.toLowerCase())) ||
|
(t.title && t.title.toLowerCase().includes(filter.toLowerCase())) ||
|
||||||
t.description.toLowerCase().includes(filter.toLowerCase())
|
t.description.toLowerCase().includes(filter.toLowerCase())
|
||||||
)
|
)
|
||||||
: tasks;
|
: [...tasks];
|
||||||
|
|
||||||
const sorted = [...filtered].sort((a, b) => {
|
// Then filter out done tasks if hideDoneTasks is enabled
|
||||||
|
if (hideDoneTasks) {
|
||||||
|
filtered = filtered.filter((t) => t.column !== "done");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then apply column filter if selected
|
||||||
|
const columnFiltered = selectedColumn
|
||||||
|
? filtered.filter((t) => t.column === selectedColumn)
|
||||||
|
: filtered;
|
||||||
|
|
||||||
|
const sorted = [...columnFiltered].sort((a, b) => {
|
||||||
let comparison = 0;
|
let comparison = 0;
|
||||||
switch (sortField) {
|
switch (sortField) {
|
||||||
case "id":
|
case "id":
|
||||||
@@ -261,12 +251,23 @@ export function ListView({
|
|||||||
};
|
};
|
||||||
sorted.forEach(task => groups[task.column].push(task));
|
sorted.forEach(task => groups[task.column].push(task));
|
||||||
return groups;
|
return groups;
|
||||||
}, [tasks, filter, sortField, sortDirection]);
|
}, [tasks, filter, sortField, sortDirection, hideDoneTasks, selectedColumn]);
|
||||||
|
|
||||||
// Calculate total filtered count from groups
|
// Calculate total filtered count from groups
|
||||||
const filteredCount = useMemo(() => {
|
const filteredCount = useMemo(() => {
|
||||||
return Object.values(groupedTasks).reduce((sum, group) => sum + group.length, 0);
|
return Object.values(groupedTasks).reduce((sum, group) => sum + group.length, 0);
|
||||||
}, [groupedTasks]);
|
}, [groupedTasks]);
|
||||||
|
|
||||||
|
// Calculate done task counts for stats display
|
||||||
|
const doneTaskCount = useMemo(() => {
|
||||||
|
return tasks.filter((t) => t.column === "done").length;
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
// Calculate hidden done tasks count
|
||||||
|
const hiddenDoneCount = useMemo(() => {
|
||||||
|
if (!hideDoneTasks) return 0;
|
||||||
|
return doneTaskCount;
|
||||||
|
}, [hideDoneTasks, doneTaskCount]);
|
||||||
const handleRowClick = useCallback(
|
const handleRowClick = useCallback(
|
||||||
async (task: Task) => {
|
async (task: Task) => {
|
||||||
try {
|
try {
|
||||||
@@ -353,14 +354,6 @@ export function ListView({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="list-section-controls">
|
|
||||||
<button className="btn btn-sm" onClick={expandAll} title="Expand all sections">
|
|
||||||
Expand All
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-sm" onClick={collapseAll} title="Collapse all sections">
|
|
||||||
Collapse All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="list-column-toggle" ref={columnDropdownRef}>
|
<div className="list-column-toggle" ref={columnDropdownRef}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm"
|
className="btn btn-sm"
|
||||||
@@ -396,8 +389,32 @@ export function ListView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm list-hide-done-toggle"
|
||||||
|
onClick={() => setHideDoneTasks((prev) => !prev)}
|
||||||
|
aria-pressed={hideDoneTasks}
|
||||||
|
title={hideDoneTasks ? "Show done tasks" : "Hide done tasks"}
|
||||||
|
>
|
||||||
|
{hideDoneTasks ? <Eye size={14} /> : <EyeOff size={14} />}
|
||||||
|
{hideDoneTasks ? "Show Done" : "Hide Done"}
|
||||||
|
</button>
|
||||||
<div className="list-stats">
|
<div className="list-stats">
|
||||||
{filteredCount} of {tasks.length} tasks
|
{selectedColumn
|
||||||
|
? `${filteredCount} of ${tasks.length} tasks in ${COLUMN_LABELS[selectedColumn]}`
|
||||||
|
: `${filteredCount} of ${tasks.length} tasks`}
|
||||||
|
{hiddenDoneCount > 0 && !selectedColumn && (
|
||||||
|
<span className="list-stats-hidden"> ({hiddenDoneCount} done hidden)</span>
|
||||||
|
)}
|
||||||
|
{selectedColumn && (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={clearColumnFilter}
|
||||||
|
aria-label="Clear column filter"
|
||||||
|
style={{ marginLeft: "8px" }}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{onNewTask && (
|
{onNewTask && (
|
||||||
<button className="btn btn-primary btn-sm" onClick={onNewTask}>
|
<button className="btn btn-primary btn-sm" onClick={onNewTask}>
|
||||||
@@ -407,26 +424,33 @@ export function ListView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="list-drop-zones">
|
<div className="list-drop-zones">
|
||||||
{COLUMNS.map((column) => (
|
{COLUMNS.map((column) => {
|
||||||
<div
|
const totalCount = tasks.filter((t) => t.column === column).length;
|
||||||
key={column}
|
const visibleCount = hideDoneTasks && column === "done" ? 0 : totalCount;
|
||||||
className={`list-drop-zone${dragOverColumn === column ? " drag-over" : ""}`}
|
const showPartial = hideDoneTasks && column === "done" && totalCount > 0;
|
||||||
onDragOver={(e) => handleColumnDragOver(e, column)}
|
|
||||||
onDragLeave={handleColumnDragLeave}
|
return (
|
||||||
onDrop={(e) => handleColumnDrop(e, column)}
|
<div
|
||||||
data-column={column}
|
key={column}
|
||||||
>
|
className={`list-drop-zone${dragOverColumn === column ? " drag-over" : ""}${selectedColumn === column ? " active" : ""}`}
|
||||||
<span className="drop-zone-dot" style={{ background: COLUMN_COLOR_MAP[column] }} />
|
onClick={() => handleColumnFilter(column)}
|
||||||
<span className="drop-zone-label">{COLUMN_LABELS[column]}</span>
|
onDragOver={(e) => handleColumnDragOver(e, column)}
|
||||||
<span className="drop-zone-count">
|
onDragLeave={handleColumnDragLeave}
|
||||||
{tasks.filter((t) => t.column === column).length}
|
onDrop={(e) => handleColumnDrop(e, column)}
|
||||||
</span>
|
data-column={column}
|
||||||
</div>
|
>
|
||||||
))}
|
<span className="drop-zone-dot" style={{ background: COLUMN_COLOR_MAP[column] }} />
|
||||||
|
<span className="drop-zone-label">{COLUMN_LABELS[column]}</span>
|
||||||
|
<span className="drop-zone-count">
|
||||||
|
{showPartial ? `${visibleCount} of ${totalCount}` : totalCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="list-table-container">
|
<div className="list-table-container">
|
||||||
{filteredCount === 0 ? (
|
{filteredCount === 0 && !isCreating ? (
|
||||||
<div className="list-empty">
|
<div className="list-empty">
|
||||||
{filter ? "No tasks match your filter" : "No tasks yet"}
|
{filter ? "No tasks match your filter" : "No tasks yet"}
|
||||||
</div>
|
</div>
|
||||||
@@ -474,139 +498,149 @@ export function ListView({
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{COLUMNS.map((column) => {
|
{COLUMNS.map((column) => {
|
||||||
|
// When column filter is active, only show the selected column
|
||||||
|
if (selectedColumn && column !== selectedColumn) return null;
|
||||||
|
|
||||||
|
// Skip done column section when hideDoneTasks is enabled (unless it's the selected column)
|
||||||
|
if (hideDoneTasks && column === "done" && !selectedColumn) return null;
|
||||||
|
|
||||||
const columnTasks = groupedTasks[column];
|
const columnTasks = groupedTasks[column];
|
||||||
const isEmpty = columnTasks.length === 0;
|
const isEmpty = columnTasks.length === 0;
|
||||||
|
|
||||||
// When filtering, hide empty sections entirely
|
// When text filtering, hide empty sections entirely (except triage when creating)
|
||||||
if (filter && isEmpty) return null;
|
if (filter && isEmpty && !(column === "triage" && isCreating)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={column}>
|
<Fragment key={column}>
|
||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
<tr
|
<tr className="list-section-header">
|
||||||
className={`list-section-header${expandedSections.has(column) ? "" : " list-section-header--collapsed"}`}
|
|
||||||
onClick={() => toggleSection(column)}
|
|
||||||
>
|
|
||||||
<th colSpan={visibleColumns.size} className="list-section-cell">
|
<th colSpan={visibleColumns.size} className="list-section-cell">
|
||||||
<span className={`list-section-chevron${expandedSections.has(column) ? " list-section-chevron--expanded" : ""}`}>
|
|
||||||
<ChevronRight size={16} />
|
|
||||||
</span>
|
|
||||||
<span className={`list-section-dot dot-${column}`} />
|
<span className={`list-section-dot dot-${column}`} />
|
||||||
<span className="list-section-title">{COLUMN_LABELS[column]}</span>
|
<span className="list-section-title">{COLUMN_LABELS[column]}</span>
|
||||||
<span className="list-section-count">{columnTasks.length} {columnTasks.length === 1 ? "task" : "tasks"}</span>
|
<span className="list-section-count">{columnTasks.length}</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{/* Task Rows - only render if section is expanded */}
|
{/* Inline Create Card for Triage column */}
|
||||||
{expandedSections.has(column) && (
|
{column === "triage" && isCreating && onCancelCreate && onCreateTask && (
|
||||||
<>
|
<tr className="list-inline-create-row">
|
||||||
{isEmpty ? (
|
<td colSpan={visibleColumns.size} className="list-inline-create-cell">
|
||||||
<tr className="list-section-empty">
|
<InlineCreateCard
|
||||||
<td colSpan={visibleColumns.size} className="list-empty-cell">
|
tasks={tasks}
|
||||||
No tasks
|
onSubmit={onCreateTask}
|
||||||
</td>
|
onCancel={onCancelCreate}
|
||||||
</tr>
|
addToast={addToast}
|
||||||
) : (
|
/>
|
||||||
columnTasks.map((task) => {
|
</td>
|
||||||
const isFailed = task.status === "failed";
|
</tr>
|
||||||
const isPaused = task.paused === true;
|
)}
|
||||||
const isAgentActive =
|
|
||||||
!globalPaused &&
|
|
||||||
!isFailed &&
|
|
||||||
!isPaused &&
|
|
||||||
(task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
|
||||||
const isDragging = draggingTaskId === task.id;
|
|
||||||
|
|
||||||
return (
|
{/* Task Rows */}
|
||||||
<tr
|
{isEmpty ? (
|
||||||
key={task.id}
|
<tr className="list-section-empty">
|
||||||
className={`list-row${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${
|
<td colSpan={visibleColumns.size} className="list-empty-cell">
|
||||||
isAgentActive ? " agent-active" : ""
|
No tasks
|
||||||
}${isDragging ? " dragging" : ""}`}
|
</td>
|
||||||
onClick={() => handleRowClick(task)}
|
</tr>
|
||||||
draggable={!isPaused}
|
) : (
|
||||||
onDragStart={(e) => handleDragStart(e, task)}
|
columnTasks.map((task) => {
|
||||||
onDragEnd={handleDragEnd}
|
const isFailed = task.status === "failed";
|
||||||
data-id={task.id}
|
const isPaused = task.paused === true;
|
||||||
>
|
const isAgentActive =
|
||||||
{visibleColumns.has("id") && (
|
!globalPaused &&
|
||||||
<td className="list-cell list-cell-id">{task.id}</td>
|
!isFailed &&
|
||||||
|
!isPaused &&
|
||||||
|
(task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
||||||
|
const isDragging = draggingTaskId === task.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={task.id}
|
||||||
|
className={`list-row${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${
|
||||||
|
isAgentActive ? " agent-active" : ""
|
||||||
|
}${isDragging ? " dragging" : ""}`}
|
||||||
|
onClick={() => handleRowClick(task)}
|
||||||
|
draggable={!isPaused}
|
||||||
|
onDragStart={(e) => handleDragStart(e, task)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
data-id={task.id}
|
||||||
|
>
|
||||||
|
{visibleColumns.has("id") && (
|
||||||
|
<td className="list-cell list-cell-id">{task.id}</td>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("title") && (
|
||||||
|
<td className="list-cell list-cell-title">
|
||||||
|
{task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "")}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("status") && (
|
||||||
|
<td className="list-cell">
|
||||||
|
{task.status ? (
|
||||||
|
<span
|
||||||
|
className={`list-status-badge${isFailed ? " failed" : ""}${
|
||||||
|
isAgentActive ? " pulsing" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{task.status}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="list-status-badge">-</span>
|
||||||
)}
|
)}
|
||||||
{visibleColumns.has("title") && (
|
</td>
|
||||||
<td className="list-cell list-cell-title">
|
)}
|
||||||
{task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "")}
|
{visibleColumns.has("column") && (
|
||||||
</td>
|
<td className="list-cell">
|
||||||
|
<span
|
||||||
|
className="list-column-badge"
|
||||||
|
style={{
|
||||||
|
background: `${COLUMN_COLOR_MAP[task.column]}20`,
|
||||||
|
color: COLUMN_COLOR_MAP[task.column],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{COLUMN_LABELS[task.column]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("createdAt") && (
|
||||||
|
<td className="list-cell list-cell-date">{formatDate(task.createdAt)}</td>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("updatedAt") && (
|
||||||
|
<td className="list-cell list-cell-date">{formatDate(task.updatedAt)}</td>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("dependencies") && (
|
||||||
|
<td className="list-cell list-cell-deps">
|
||||||
|
{task.dependencies && task.dependencies.length > 0 ? (
|
||||||
|
<span className="list-dep-badge" title={task.dependencies.join(", ")}>
|
||||||
|
<Link size={12} /> {task.dependencies.length}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
)}
|
)}
|
||||||
{visibleColumns.has("status") && (
|
</td>
|
||||||
<td className="list-cell">
|
)}
|
||||||
{task.status ? (
|
{visibleColumns.has("progress") && (
|
||||||
<span
|
<td className="list-cell list-cell-progress">
|
||||||
className={`list-status-badge${isFailed ? " failed" : ""}${
|
{task.steps.length > 0 ? (
|
||||||
isAgentActive ? " pulsing" : ""
|
<div className="list-progress">
|
||||||
}`}
|
<div className="list-progress-bar">
|
||||||
>
|
<div
|
||||||
{task.status}
|
className="list-progress-fill"
|
||||||
</span>
|
style={{
|
||||||
) : (
|
width: `${getStepProgressPercent(task.steps)}%`,
|
||||||
<span className="list-status-badge">-</span>
|
backgroundColor: COLUMN_COLOR_MAP[task.column],
|
||||||
)}
|
}}
|
||||||
</td>
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="list-progress-label">{getStepProgress(task.steps)}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
)}
|
)}
|
||||||
{visibleColumns.has("column") && (
|
</td>
|
||||||
<td className="list-cell">
|
)}
|
||||||
<span
|
</tr>
|
||||||
className="list-column-badge"
|
);
|
||||||
style={{
|
})
|
||||||
background: `${COLUMN_COLOR_MAP[task.column]}20`,
|
|
||||||
color: COLUMN_COLOR_MAP[task.column],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{COLUMN_LABELS[task.column]}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has("createdAt") && (
|
|
||||||
<td className="list-cell list-cell-date">{formatDate(task.createdAt)}</td>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has("updatedAt") && (
|
|
||||||
<td className="list-cell list-cell-date">{formatDate(task.updatedAt)}</td>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has("dependencies") && (
|
|
||||||
<td className="list-cell list-cell-deps">
|
|
||||||
{task.dependencies && task.dependencies.length > 0 ? (
|
|
||||||
<span className="list-dep-badge" title={task.dependencies.join(", ")}>
|
|
||||||
<Link size={12} /> {task.dependencies.length}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"-"
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has("progress") && (
|
|
||||||
<td className="list-cell list-cell-progress">
|
|
||||||
{task.steps.length > 0 ? (
|
|
||||||
<div className="list-progress">
|
|
||||||
<div className="list-progress-bar">
|
|
||||||
<div
|
|
||||||
className="list-progress-fill"
|
|
||||||
style={{
|
|
||||||
width: `${getStepProgressPercent(task.steps)}%`,
|
|
||||||
backgroundColor: COLUMN_COLOR_MAP[task.column],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="list-progress-label">{getStepProgress(task.steps)}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
"-"
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const SETTINGS_SECTIONS = [
|
|||||||
{ id: "worktrees", label: "Worktrees" },
|
{ id: "worktrees", label: "Worktrees" },
|
||||||
{ id: "commands", label: "Commands" },
|
{ id: "commands", label: "Commands" },
|
||||||
{ id: "merge", label: "Merge" },
|
{ id: "merge", label: "Merge" },
|
||||||
|
{ id: "notifications", label: "Notifications" },
|
||||||
{ id: "authentication", label: "Authentication" },
|
{ id: "authentication", label: "Authentication" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -488,6 +489,52 @@ export function SettingsModal({ onClose, addToast, initialSection }: SettingsMod
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
case "notifications":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h4 className="settings-section-heading">Notifications</h4>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="ntfyEnabled" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="ntfyEnabled"
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.ntfyEnabled || false}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, ntfyEnabled: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Enable ntfy.sh notifications
|
||||||
|
</label>
|
||||||
|
<small>Receive push notifications when tasks complete or fail via ntfy.sh</small>
|
||||||
|
</div>
|
||||||
|
{form.ntfyEnabled && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="ntfyTopic">ntfy Topic</label>
|
||||||
|
<input
|
||||||
|
id="ntfyTopic"
|
||||||
|
type="text"
|
||||||
|
placeholder="my-topic-name"
|
||||||
|
value={form.ntfyTopic || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setForm((f) => ({ ...f, ntfyTopic: val || undefined }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<small>
|
||||||
|
Your ntfy.sh topic name (1–64 alphanumeric/hyphen/underscore characters).{" "}
|
||||||
|
<a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer">
|
||||||
|
Learn more about ntfy.sh
|
||||||
|
</a>
|
||||||
|
</small>
|
||||||
|
{form.ntfyTopic && !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic) && (
|
||||||
|
<small className="field-error">
|
||||||
|
Topic must be 1–64 alphanumeric, hyphen, or underscore characters
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
case "authentication":
|
case "authentication":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -370,6 +370,9 @@ export function TaskCard({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="card-title">
|
||||||
|
{task.title || (task.description ? task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "") : task.id)}
|
||||||
|
</div>
|
||||||
{task.steps.length > 0 && (() => {
|
{task.steps.length > 0 && (() => {
|
||||||
const completedSteps = task.steps.filter(s => s.status === "done").length;
|
const completedSteps = task.steps.filter(s => s.status === "done").length;
|
||||||
const totalSteps = task.steps.length;
|
const totalSteps = task.steps.length;
|
||||||
@@ -421,9 +424,6 @@ export function TaskCard({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
<div className="card-title">
|
|
||||||
{task.title || (task.description ? task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "") : task.id)}
|
|
||||||
</div>
|
|
||||||
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (
|
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (
|
||||||
<div className="card-meta">
|
<div className="card-meta">
|
||||||
{task.dependencies && task.dependencies.length > 0 && (
|
{task.dependencies && task.dependencies.length > 0 && (
|
||||||
|
|||||||
@@ -667,6 +667,185 @@ describe("ListView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ListView Column Filtering", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters tasks by column when drop zone is clicked", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
|
||||||
|
createMockTask({ id: "KB-002", column: "todo", title: "Todo Task" }),
|
||||||
|
createMockTask({ id: "KB-003", column: "in-progress", title: "In Progress Task" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Click on the triage drop zone
|
||||||
|
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
|
||||||
|
fireEvent.click(triageZone);
|
||||||
|
|
||||||
|
// Only triage task should be visible
|
||||||
|
expect(screen.getByText("KB-001")).toBeDefined();
|
||||||
|
expect(screen.queryByText("KB-002")).toBeNull();
|
||||||
|
expect(screen.queryByText("KB-003")).toBeNull();
|
||||||
|
|
||||||
|
// Only triage section header should be visible
|
||||||
|
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||||
|
expect(sectionHeaders.length).toBe(1);
|
||||||
|
expect(sectionHeaders[0].textContent).toContain("Triage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears column filter when same drop zone is clicked again", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
|
||||||
|
createMockTask({ id: "KB-002", column: "todo", title: "Todo Task" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Click on the triage drop zone to filter
|
||||||
|
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
|
||||||
|
fireEvent.click(triageZone);
|
||||||
|
|
||||||
|
// Verify filter is active - only triage task visible
|
||||||
|
expect(screen.getByText("KB-001")).toBeDefined();
|
||||||
|
expect(screen.queryByText("KB-002")).toBeNull();
|
||||||
|
|
||||||
|
// Click the same drop zone again to clear filter
|
||||||
|
fireEvent.click(triageZone);
|
||||||
|
|
||||||
|
// All tasks should be visible again
|
||||||
|
expect(screen.getByText("KB-001")).toBeDefined();
|
||||||
|
expect(screen.getByText("KB-002")).toBeDefined();
|
||||||
|
|
||||||
|
// All 5 section headers should be visible
|
||||||
|
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||||
|
expect(sectionHeaders.length).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches column filter when different drop zone is clicked", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
|
||||||
|
createMockTask({ id: "KB-002", column: "todo", title: "Todo Task" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Click on the triage drop zone to filter
|
||||||
|
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
|
||||||
|
fireEvent.click(triageZone);
|
||||||
|
|
||||||
|
// Verify only triage task visible
|
||||||
|
expect(screen.getByText("KB-001")).toBeDefined();
|
||||||
|
expect(screen.queryByText("KB-002")).toBeNull();
|
||||||
|
|
||||||
|
// Click on the todo drop zone to switch filter
|
||||||
|
const todoZone = document.querySelector('[data-column="todo"].list-drop-zone')!;
|
||||||
|
fireEvent.click(todoZone);
|
||||||
|
|
||||||
|
// Only todo task should be visible now
|
||||||
|
expect(screen.queryByText("KB-001")).toBeNull();
|
||||||
|
expect(screen.getByText("KB-002")).toBeDefined();
|
||||||
|
|
||||||
|
// Only todo section header should be visible
|
||||||
|
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||||
|
expect(sectionHeaders.length).toBe(1);
|
||||||
|
expect(sectionHeaders[0].textContent).toContain("Todo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears column filter when clear button is clicked", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
|
||||||
|
createMockTask({ id: "KB-002", column: "todo", title: "Todo Task" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Click on the triage drop zone to filter
|
||||||
|
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
|
||||||
|
fireEvent.click(triageZone);
|
||||||
|
|
||||||
|
// Verify filter is active
|
||||||
|
expect(screen.queryByText("KB-002")).toBeNull();
|
||||||
|
|
||||||
|
// Click the clear button
|
||||||
|
const clearButton = screen.getByRole("button", { name: /clear column filter/i });
|
||||||
|
fireEvent.click(clearButton);
|
||||||
|
|
||||||
|
// All tasks should be visible again
|
||||||
|
expect(screen.getByText("KB-001")).toBeDefined();
|
||||||
|
expect(screen.getByText("KB-002")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows correct filtered stats when column filter is active", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
|
||||||
|
createMockTask({ id: "KB-002", column: "triage", title: "Triage Task 2" }),
|
||||||
|
createMockTask({ id: "KB-003", column: "todo", title: "Todo Task" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Click on the triage drop zone to filter
|
||||||
|
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
|
||||||
|
fireEvent.click(triageZone);
|
||||||
|
|
||||||
|
// Stats should show filtered count with column name
|
||||||
|
expect(screen.getByText("2 of 3 tasks in Triage")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies text filter within column filter", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", column: "triage", title: "Alpha Triage Task" }),
|
||||||
|
createMockTask({ id: "KB-002", column: "triage", title: "Beta Triage Task" }),
|
||||||
|
createMockTask({ id: "KB-003", column: "todo", title: "Alpha Todo Task" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Click on the triage drop zone to filter by column
|
||||||
|
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
|
||||||
|
fireEvent.click(triageZone);
|
||||||
|
|
||||||
|
// Both triage tasks should be visible
|
||||||
|
expect(screen.getByText("KB-001")).toBeDefined();
|
||||||
|
expect(screen.getByText("KB-002")).toBeDefined();
|
||||||
|
expect(screen.queryByText("KB-003")).toBeNull();
|
||||||
|
|
||||||
|
// Apply text filter within the triage column
|
||||||
|
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
|
||||||
|
fireEvent.change(filterInput, { target: { value: "Alpha" } });
|
||||||
|
|
||||||
|
// Only Alpha triage task should be visible
|
||||||
|
expect(screen.getByText("KB-001")).toBeDefined();
|
||||||
|
expect(screen.queryByText("KB-002")).toBeNull();
|
||||||
|
expect(screen.queryByText("KB-003")).toBeNull();
|
||||||
|
|
||||||
|
// Stats should reflect combined filtering
|
||||||
|
expect(screen.getByText("1 of 3 tasks in Triage")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies active class to selected column drop zone", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Click on the triage drop zone
|
||||||
|
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
|
||||||
|
fireEvent.click(triageZone);
|
||||||
|
|
||||||
|
// Should have active class
|
||||||
|
expect(triageZone.classList.contains("active")).toBe(true);
|
||||||
|
|
||||||
|
// Other drop zones should not have active class
|
||||||
|
const todoZone = document.querySelector('[data-column="todo"].list-drop-zone')!;
|
||||||
|
expect(todoZone.classList.contains("active")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("ListView Column Visibility", () => {
|
describe("ListView Column Visibility", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -854,264 +1033,259 @@ describe("ListView Column Visibility", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ListView Collapsible Sections", () => {
|
|
||||||
|
describe("ListView Hide Done Tasks", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Clear localStorage before each test
|
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("collapses and expands section when header is clicked", () => {
|
it("renders hide done tasks toggle button", () => {
|
||||||
|
renderListView();
|
||||||
|
|
||||||
|
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||||
|
expect(hideDoneButton).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides done tasks when toggle is activated", () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
createMockTask({ id: "KB-001", title: "Task 1", column: "triage" }),
|
createMockTask({ id: "KB-001", column: "done" }),
|
||||||
createMockTask({ id: "KB-002", title: "Task 2", column: "triage" }),
|
createMockTask({ id: "KB-002", column: "triage" }),
|
||||||
];
|
];
|
||||||
|
|
||||||
renderListView({ tasks });
|
renderListView({ tasks });
|
||||||
|
|
||||||
// Initially, task should be visible
|
// Both tasks should be visible initially
|
||||||
expect(screen.getByText("KB-001")).toBeDefined();
|
expect(screen.getByText("KB-001")).toBeDefined();
|
||||||
expect(screen.getByText("KB-002")).toBeDefined();
|
expect(screen.getByText("KB-002")).toBeDefined();
|
||||||
|
|
||||||
// Find and click the triage section header
|
// Click hide done button
|
||||||
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||||
const triageHeader = sectionHeaders.find(h => h.textContent?.includes("Triage"));
|
fireEvent.click(hideDoneButton);
|
||||||
expect(triageHeader).toBeDefined();
|
|
||||||
fireEvent.click(triageHeader!);
|
|
||||||
|
|
||||||
// After collapse, tasks should be hidden but header still visible
|
// Done task should be hidden, triage task should still be visible
|
||||||
expect(screen.queryByText("KB-001")).toBeNull();
|
expect(screen.queryByText("KB-001")).toBeNull();
|
||||||
expect(screen.queryByText("KB-002")).toBeNull();
|
|
||||||
expect(triageHeader).toBeDefined();
|
|
||||||
|
|
||||||
// Click header again to expand
|
|
||||||
fireEvent.click(triageHeader!);
|
|
||||||
|
|
||||||
// Tasks should be visible again
|
|
||||||
expect(screen.getByText("KB-001")).toBeDefined();
|
|
||||||
expect(screen.getByText("KB-002")).toBeDefined();
|
expect(screen.getByText("KB-002")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists section expansion state to localStorage", () => {
|
it("shows done tasks when toggle is deactivated", () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
createMockTask({ id: "KB-001", title: "Task 1", column: "triage" }),
|
createMockTask({ id: "KB-001", column: "done" }),
|
||||||
|
createMockTask({ id: "KB-002", column: "triage" }),
|
||||||
];
|
];
|
||||||
|
|
||||||
renderListView({ tasks });
|
renderListView({ tasks });
|
||||||
|
|
||||||
// Collapse the triage section
|
// Click hide done button to hide done tasks
|
||||||
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||||
const triageHeader = sectionHeaders.find(h => h.textContent?.includes("Triage"));
|
fireEvent.click(hideDoneButton);
|
||||||
fireEvent.click(triageHeader!);
|
|
||||||
|
|
||||||
// Verify localStorage was updated with only expanded columns
|
// Done task should be hidden
|
||||||
const saved = localStorage.getItem("kb-dashboard-list-sections");
|
|
||||||
expect(saved).toBeTruthy();
|
|
||||||
const parsed = JSON.parse(saved!) as string[];
|
|
||||||
expect(parsed).not.toContain("triage");
|
|
||||||
// Other columns should still be expanded
|
|
||||||
expect(parsed).toContain("todo");
|
|
||||||
expect(parsed).toContain("in-progress");
|
|
||||||
expect(parsed).toContain("in-review");
|
|
||||||
expect(parsed).toContain("done");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("restores section expansion state from localStorage on mount", () => {
|
|
||||||
// Set up localStorage with only todo expanded (triage collapsed)
|
|
||||||
localStorage.setItem("kb-dashboard-list-sections", JSON.stringify(["todo", "in-progress", "in-review", "done"]));
|
|
||||||
|
|
||||||
const tasks = [
|
|
||||||
createMockTask({ id: "KB-001", title: "Triage Task", column: "triage" }),
|
|
||||||
createMockTask({ id: "KB-002", title: "Todo Task", column: "todo" }),
|
|
||||||
];
|
|
||||||
|
|
||||||
renderListView({ tasks });
|
|
||||||
|
|
||||||
// Triage task should be hidden (collapsed)
|
|
||||||
expect(screen.queryByText("KB-001")).toBeNull();
|
expect(screen.queryByText("KB-001")).toBeNull();
|
||||||
|
|
||||||
// Todo task should be visible (expanded)
|
// Click again to show done tasks
|
||||||
expect(screen.getByText("KB-002")).toBeDefined();
|
fireEvent.click(hideDoneButton);
|
||||||
|
|
||||||
// Triage header should still be visible with collapsed styling
|
// Both tasks should be visible again
|
||||||
const triageHeader = screen.getAllByRole("row")
|
expect(screen.getByText("KB-001")).toBeDefined();
|
||||||
.find(r => r.className.includes("list-section-header") && r.textContent?.includes("Triage"));
|
expect(screen.getByText("KB-002")).toBeDefined();
|
||||||
expect(triageHeader).toBeDefined();
|
|
||||||
expect(triageHeader?.className).toContain("list-section-header--collapsed");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("expand all button expands all collapsed sections", () => {
|
it("persists hide done preference to localStorage", () => {
|
||||||
const tasks = [
|
const tasks = [createMockTask({ id: "KB-001", column: "done" })];
|
||||||
createMockTask({ id: "KB-001", title: "Triage Task", column: "triage" }),
|
|
||||||
createMockTask({ id: "KB-002", title: "Todo Task", column: "todo" }),
|
|
||||||
];
|
|
||||||
|
|
||||||
renderListView({ tasks });
|
renderListView({ tasks });
|
||||||
|
|
||||||
// Collapse triage section
|
// Click hide done button
|
||||||
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||||
const triageHeader = sectionHeaders.find(h => h.textContent?.includes("Triage"));
|
fireEvent.click(hideDoneButton);
|
||||||
fireEvent.click(triageHeader!);
|
|
||||||
|
|
||||||
// Verify triage task is hidden
|
// Verify localStorage was updated
|
||||||
|
expect(localStorage.getItem("kb-dashboard-hide-done")).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializes hide done state from localStorage", () => {
|
||||||
|
// Set up localStorage with hide done enabled
|
||||||
|
localStorage.setItem("kb-dashboard-hide-done", "true");
|
||||||
|
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", column: "done" }),
|
||||||
|
createMockTask({ id: "KB-002", column: "triage" }),
|
||||||
|
];
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Button should show "Show Done" text since done tasks are hidden
|
||||||
|
expect(screen.getByRole("button", { name: /show done/i })).toBeDefined();
|
||||||
|
|
||||||
|
// Done task should be hidden initially
|
||||||
expect(screen.queryByText("KB-001")).toBeNull();
|
expect(screen.queryByText("KB-001")).toBeNull();
|
||||||
|
|
||||||
// Click Expand All button
|
|
||||||
const expandAllButton = screen.getByText("Expand All");
|
|
||||||
fireEvent.click(expandAllButton);
|
|
||||||
|
|
||||||
// All tasks should be visible now
|
|
||||||
expect(screen.getByText("KB-001")).toBeDefined();
|
|
||||||
expect(screen.getByText("KB-002")).toBeDefined();
|
expect(screen.getByText("KB-002")).toBeDefined();
|
||||||
|
|
||||||
// Verify localStorage has all columns
|
|
||||||
const saved = localStorage.getItem("kb-dashboard-list-sections");
|
|
||||||
const parsed = JSON.parse(saved!) as string[];
|
|
||||||
expect(parsed).toContain("triage");
|
|
||||||
expect(parsed).toContain("todo");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("collapse all button collapses all expanded sections", () => {
|
it("updates stats text when done tasks are hidden", () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
createMockTask({ id: "KB-001", title: "Triage Task", column: "triage" }),
|
createMockTask({ id: "KB-001", column: "done" }),
|
||||||
createMockTask({ id: "KB-002", title: "Todo Task", column: "todo" }),
|
createMockTask({ id: "KB-002", column: "triage" }),
|
||||||
|
createMockTask({ id: "KB-003", column: "done" }),
|
||||||
];
|
];
|
||||||
|
|
||||||
renderListView({ tasks });
|
renderListView({ tasks });
|
||||||
|
|
||||||
// Initially tasks should be visible
|
// Initial stats should show all tasks
|
||||||
expect(screen.getByText("KB-001")).toBeDefined();
|
expect(screen.getByText("3 of 3 tasks")).toBeDefined();
|
||||||
expect(screen.getByText("KB-002")).toBeDefined();
|
|
||||||
|
|
||||||
// Click Collapse All button
|
// Click hide done button
|
||||||
const collapseAllButton = screen.getByText("Collapse All");
|
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||||
fireEvent.click(collapseAllButton);
|
fireEvent.click(hideDoneButton);
|
||||||
|
|
||||||
// All tasks should be hidden now
|
// Stats should show filtered count with hidden indicator
|
||||||
|
expect(screen.getByText("1 of 3 tasks")).toBeDefined();
|
||||||
|
expect(screen.getByText(/2 done hidden/)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides done column section header when hide done is active", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", column: "done" }),
|
||||||
|
createMockTask({ id: "KB-002", column: "triage" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// All section headers should be visible initially
|
||||||
|
const sectionHeadersBefore = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||||
|
expect(sectionHeadersBefore.length).toBe(5); // All 5 columns
|
||||||
|
|
||||||
|
// Click hide done button
|
||||||
|
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||||
|
fireEvent.click(hideDoneButton);
|
||||||
|
|
||||||
|
// Done section should be hidden - find section headers and verify done is not present
|
||||||
|
const doneSection = screen.getAllByRole("row").find(r =>
|
||||||
|
r.className.includes("list-section-header") && r.textContent?.includes("Done")
|
||||||
|
);
|
||||||
|
expect(doneSection).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows done drop zone with count when hide done is active", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", column: "done" }),
|
||||||
|
createMockTask({ id: "KB-002", column: "done" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Click hide done button
|
||||||
|
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||||
|
fireEvent.click(hideDoneButton);
|
||||||
|
|
||||||
|
// Done drop zone should still be visible with "X of Y" format
|
||||||
|
const doneZone = document.querySelector('[data-column="done"].list-drop-zone');
|
||||||
|
expect(doneZone).toBeDefined();
|
||||||
|
expect(doneZone?.textContent).toContain("0 of 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves hide done state through filter changes", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", column: "done", title: "Alpha" }),
|
||||||
|
createMockTask({ id: "KB-002", column: "triage", title: "Beta" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Hide done tasks
|
||||||
|
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||||
|
fireEvent.click(hideDoneButton);
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
|
||||||
|
fireEvent.change(filterInput, { target: { value: "Beta" } });
|
||||||
|
|
||||||
|
// Done task should remain hidden
|
||||||
expect(screen.queryByText("KB-001")).toBeNull();
|
expect(screen.queryByText("KB-001")).toBeNull();
|
||||||
expect(screen.queryByText("KB-002")).toBeNull();
|
// Filtered task should be visible
|
||||||
|
|
||||||
// Section headers should still be visible
|
|
||||||
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
|
||||||
expect(sectionHeaders.length).toBe(5); // All 5 columns
|
|
||||||
|
|
||||||
// Verify localStorage is empty (no expanded sections)
|
|
||||||
const saved = localStorage.getItem("kb-dashboard-list-sections");
|
|
||||||
const parsed = JSON.parse(saved!) as string[];
|
|
||||||
expect(parsed.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("collapsed sections hide task rows but keep header visible", () => {
|
|
||||||
const tasks = [
|
|
||||||
createMockTask({ id: "KB-001", title: "Task 1", column: "triage" }),
|
|
||||||
];
|
|
||||||
|
|
||||||
renderListView({ tasks });
|
|
||||||
|
|
||||||
// Get the triage section header
|
|
||||||
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
|
||||||
const triageHeader = sectionHeaders.find(h => h.textContent?.includes("Triage"));
|
|
||||||
expect(triageHeader).toBeDefined();
|
|
||||||
|
|
||||||
// Verify task is visible before collapse
|
|
||||||
expect(screen.getByText("KB-001")).toBeDefined();
|
|
||||||
|
|
||||||
// Collapse the section
|
|
||||||
fireEvent.click(triageHeader!);
|
|
||||||
|
|
||||||
// Header should still be visible
|
|
||||||
expect(triageHeader).toBeDefined();
|
|
||||||
// But with collapsed class
|
|
||||||
expect(triageHeader?.className).toContain("list-section-header--collapsed");
|
|
||||||
|
|
||||||
// Task should be hidden
|
|
||||||
expect(screen.queryByText("KB-001")).toBeNull();
|
|
||||||
|
|
||||||
// Count badge should still show "1 task"
|
|
||||||
expect(triageHeader?.textContent).toContain("1 task");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drag and drop still works in expanded sections", () => {
|
|
||||||
const tasks = [createMockTask({ id: "KB-001", column: "triage" })];
|
|
||||||
const mockOnMoveTask = vi.fn(() => Promise.resolve(tasks[0]));
|
|
||||||
|
|
||||||
renderListView({ tasks, onMoveTask: mockOnMoveTask });
|
|
||||||
|
|
||||||
// Find the task row
|
|
||||||
const row = screen.getByText("KB-001").closest("tr")!;
|
|
||||||
|
|
||||||
// Simulate drag start
|
|
||||||
fireEvent.dragStart(row, {
|
|
||||||
dataTransfer: {
|
|
||||||
setData: vi.fn(),
|
|
||||||
effectAllowed: "move",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Simulate drop on todo column drop zone
|
|
||||||
const todoZone = document.querySelector('[data-column="todo"].list-drop-zone')!;
|
|
||||||
fireEvent.drop(todoZone, {
|
|
||||||
preventDefault: vi.fn(),
|
|
||||||
dataTransfer: {
|
|
||||||
getData: vi.fn(() => "KB-001"),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify onMoveTask was called
|
|
||||||
expect(mockOnMoveTask).toHaveBeenCalledWith("KB-001", "todo");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows No tasks placeholder for empty expanded sections", () => {
|
|
||||||
// Create tasks only in triage, so other columns show "No tasks" placeholders
|
|
||||||
const tasks = [createMockTask({ id: "KB-001", column: "triage" })];
|
|
||||||
|
|
||||||
renderListView({ tasks });
|
|
||||||
|
|
||||||
// Should see 4 "No tasks" placeholders for empty columns (todo, in-progress, in-review, done)
|
|
||||||
const noTasksCells = screen.getAllByText("No tasks");
|
|
||||||
expect(noTasksCells.length).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides No tasks placeholder when section is collapsed", () => {
|
|
||||||
// Create tasks only in triage
|
|
||||||
const tasks = [createMockTask({ id: "KB-001", column: "triage" })];
|
|
||||||
|
|
||||||
renderListView({ tasks });
|
|
||||||
|
|
||||||
// Initially 4 sections show "No tasks" (todo, in-progress, in-review, done)
|
|
||||||
let noTasksCells = screen.getAllByText("No tasks");
|
|
||||||
expect(noTasksCells.length).toBe(4);
|
|
||||||
|
|
||||||
// Collapse the todo section (which has "No tasks" placeholder)
|
|
||||||
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
|
||||||
const todoHeader = sectionHeaders.find(h => h.textContent?.includes("Todo"));
|
|
||||||
fireEvent.click(todoHeader!);
|
|
||||||
|
|
||||||
// Now should only show 3 "No tasks" placeholders (todo is collapsed)
|
|
||||||
noTasksCells = screen.getAllByText("No tasks");
|
|
||||||
expect(noTasksCells.length).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("all sections expanded by default when no localStorage", () => {
|
|
||||||
const tasks = [
|
|
||||||
createMockTask({ id: "KB-001", column: "triage" }),
|
|
||||||
createMockTask({ id: "KB-002", column: "todo" }),
|
|
||||||
];
|
|
||||||
|
|
||||||
renderListView({ tasks });
|
|
||||||
|
|
||||||
// All tasks should be visible by default
|
|
||||||
expect(screen.getByText("KB-001")).toBeDefined();
|
|
||||||
expect(screen.getByText("KB-002")).toBeDefined();
|
expect(screen.getByText("KB-002")).toBeDefined();
|
||||||
|
});
|
||||||
// Verify localStorage was initialized with all columns
|
});
|
||||||
const saved = localStorage.getItem("kb-dashboard-list-sections");
|
|
||||||
expect(saved).toBeTruthy();
|
describe("ListView Inline Create Card", () => {
|
||||||
const parsed = JSON.parse(saved!) as string[];
|
beforeEach(() => {
|
||||||
expect(parsed).toContain("triage");
|
vi.clearAllMocks();
|
||||||
expect(parsed).toContain("todo");
|
});
|
||||||
expect(parsed).toContain("in-progress");
|
|
||||||
expect(parsed).toContain("in-review");
|
it("shows InlineCreateCard when isCreating is true", () => {
|
||||||
expect(parsed).toContain("done");
|
renderListView({ isCreating: true, onCancelCreate: vi.fn(), onCreateTask: vi.fn() });
|
||||||
|
|
||||||
|
// The inline creation card should be visible with its textarea
|
||||||
|
expect(screen.getByPlaceholderText("What needs to be done?")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show InlineCreateCard when isCreating is false", () => {
|
||||||
|
renderListView({ isCreating: false, onCancelCreate: vi.fn(), onCreateTask: vi.fn() });
|
||||||
|
|
||||||
|
// The inline creation card should not be visible
|
||||||
|
expect(screen.queryByPlaceholderText("What needs to be done?")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show InlineCreateCard when onCancelCreate is not provided", () => {
|
||||||
|
renderListView({ isCreating: true, onCreateTask: vi.fn() });
|
||||||
|
|
||||||
|
// The inline creation card should not be visible without onCancelCreate
|
||||||
|
expect(screen.queryByPlaceholderText("What needs to be done?")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show InlineCreateCard when onCreateTask is not provided", () => {
|
||||||
|
renderListView({ isCreating: true, onCancelCreate: vi.fn() });
|
||||||
|
|
||||||
|
// The inline creation card should not be visible without onCreateTask
|
||||||
|
expect(screen.queryByPlaceholderText("What needs to be done?")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onCreateTask with triage column when task is submitted from inline card", async () => {
|
||||||
|
const mockOnCreateTask = vi.fn().mockResolvedValue(createMockTask({ id: "KB-002" }));
|
||||||
|
renderListView({ isCreating: true, onCancelCreate: vi.fn(), onCreateTask: mockOnCreateTask });
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||||
|
fireEvent.change(textarea, { target: { value: "New task description" } });
|
||||||
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnCreateTask).toHaveBeenCalledWith({
|
||||||
|
description: "New task description",
|
||||||
|
column: "triage",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onCancelCreate when inline card is cancelled via blur", () => {
|
||||||
|
const mockOnCancelCreate = vi.fn();
|
||||||
|
renderListView({ isCreating: true, onCancelCreate: mockOnCancelCreate, onCreateTask: vi.fn() });
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||||
|
textarea.focus();
|
||||||
|
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||||
|
|
||||||
|
expect(mockOnCancelCreate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onCancelCreate when inline card is cancelled via Escape key", () => {
|
||||||
|
const mockOnCancelCreate = vi.fn();
|
||||||
|
renderListView({ isCreating: true, onCancelCreate: mockOnCancelCreate, onCreateTask: vi.fn() });
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||||
|
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||||
|
|
||||||
|
expect(mockOnCancelCreate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders InlineCreateCard in triage section with correct colSpan", () => {
|
||||||
|
renderListView({ isCreating: true, onCancelCreate: vi.fn(), onCreateTask: vi.fn() });
|
||||||
|
|
||||||
|
// Find the inline create row
|
||||||
|
const inlineCreateRow = document.querySelector(".list-inline-create-row");
|
||||||
|
expect(inlineCreateRow).toBeTruthy();
|
||||||
|
|
||||||
|
// Check that the cell has the correct colSpan (8 columns by default)
|
||||||
|
const inlineCreateCell = document.querySelector(".list-inline-create-cell");
|
||||||
|
expect(inlineCreateCell).toBeTruthy();
|
||||||
|
expect(inlineCreateCell?.getAttribute("colspan")).toBe("8");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const defaultSettings: Settings = {
|
|||||||
buildCommand: "",
|
buildCommand: "",
|
||||||
autoResolveConflicts: true,
|
autoResolveConflicts: true,
|
||||||
smartConflictResolution: true,
|
smartConflictResolution: true,
|
||||||
|
ntfyEnabled: false,
|
||||||
|
ntfyTopic: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
@@ -681,17 +683,17 @@ describe("SettingsModal", () => {
|
|||||||
expect(layout!.querySelector(".settings-content")).toBeTruthy();
|
expect(layout!.querySelector(".settings-content")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has .settings-sidebar with 7 .settings-nav-item buttons for all sections", async () => {
|
it("has .settings-sidebar with 8 .settings-nav-item buttons for all sections", async () => {
|
||||||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
const sidebar = container.querySelector(".settings-sidebar");
|
const sidebar = container.querySelector(".settings-sidebar");
|
||||||
expect(sidebar).toBeTruthy();
|
expect(sidebar).toBeTruthy();
|
||||||
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
|
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
|
||||||
expect(navItems.length).toBe(7);
|
expect(navItems.length).toBe(8);
|
||||||
|
|
||||||
const labels = Array.from(navItems).map((el) => el.textContent);
|
const labels = Array.from(navItems).map((el) => el.textContent);
|
||||||
expect(labels).toEqual(["General", "Model", "Scheduling", "Worktrees", "Commands", "Merge", "Authentication"]);
|
expect(labels).toEqual(["General", "Model", "Scheduling", "Worktrees", "Commands", "Merge", "Notifications", "Authentication"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has .settings-content as sibling of .settings-sidebar", async () => {
|
it("has .settings-content as sibling of .settings-sidebar", async () => {
|
||||||
@@ -744,4 +746,136 @@ describe("SettingsModal", () => {
|
|||||||
expect(row.querySelector("button")).toBeTruthy();
|
expect(row.querySelector("button")).toBeTruthy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Notifications section tests ---
|
||||||
|
|
||||||
|
it("shows Notifications in sidebar", async () => {
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
expect(screen.getAllByText("Notifications").length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows ntfy enable checkbox in Notifications section", async () => {
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Notifications"));
|
||||||
|
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||||||
|
expect(checkbox).toBeTruthy();
|
||||||
|
expect(checkbox.getAttribute("type")).toBe("checkbox");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ntfy topic input is hidden when ntfy is disabled", async () => {
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Notifications"));
|
||||||
|
expect(screen.queryByLabelText("ntfy Topic")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ntfy topic input is visible when ntfy is enabled", async () => {
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Notifications"));
|
||||||
|
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||||||
|
fireEvent.click(checkbox);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("ntfy Topic")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggling ntfyEnabled checkbox sends true in save payload", async () => {
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Notifications"));
|
||||||
|
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||||||
|
fireEvent.click(checkbox);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Save"));
|
||||||
|
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||||
|
expect(payload.ntfyEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ntfy topic field saves correctly when set", async () => {
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Notifications"));
|
||||||
|
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||||||
|
fireEvent.click(checkbox);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: "my-topic" } });
|
||||||
|
expect(input.value).toBe("my-topic");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Save"));
|
||||||
|
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||||
|
expect(payload.ntfyTopic).toBe("my-topic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ntfy topic field submits undefined when empty", async () => {
|
||||||
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
...defaultSettings,
|
||||||
|
ntfyEnabled: true,
|
||||||
|
ntfyTopic: "existing-topic",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Notifications"));
|
||||||
|
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: "" } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Save"));
|
||||||
|
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||||
|
expect(payload.ntfyTopic).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ntfy topic shows validation error for invalid input", async () => {
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Notifications"));
|
||||||
|
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||||||
|
fireEvent.click(checkbox);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: "invalid topic with spaces!" } });
|
||||||
|
|
||||||
|
expect(screen.getByText("Topic must be 1–64 alphanumeric, hyphen, or underscore characters")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ntfyEnabled defaults to false when setting is undefined", async () => {
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Notifications"));
|
||||||
|
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications") as HTMLInputElement;
|
||||||
|
expect(checkbox.checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ntfyEnabled shows correct state when enabled in settings", async () => {
|
||||||
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
...defaultSettings,
|
||||||
|
ntfyEnabled: true,
|
||||||
|
ntfyTopic: "my-topic",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Notifications"));
|
||||||
|
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications") as HTMLInputElement;
|
||||||
|
expect(checkbox.checked).toBe(true);
|
||||||
|
expect(screen.getByLabelText("ntfy Topic")).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2306,6 +2306,11 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-stats-hidden {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
/* Column toggle dropdown */
|
/* Column toggle dropdown */
|
||||||
.list-column-toggle {
|
.list-column-toggle {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -2378,6 +2383,13 @@ body {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide done tasks toggle */
|
||||||
|
.list-hide-done-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Drop zones for drag and drop */
|
/* Drop zones for drag and drop */
|
||||||
.list-drop-zones {
|
.list-drop-zones {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2406,6 +2418,12 @@ body {
|
|||||||
border-color: var(--text-muted);
|
border-color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-drop-zone.active {
|
||||||
|
border-color: var(--todo);
|
||||||
|
background: rgba(88, 166, 255, 0.15);
|
||||||
|
box-shadow: 0 0 0 1px var(--todo);
|
||||||
|
}
|
||||||
|
|
||||||
.list-drop-zone.drag-over {
|
.list-drop-zone.drag-over {
|
||||||
border-color: var(--todo);
|
border-color: var(--todo);
|
||||||
box-shadow: 0 0 0 1px var(--todo);
|
box-shadow: 0 0 0 1px var(--todo);
|
||||||
|
|||||||
@@ -10,4 +10,4 @@ export { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees } from "./wor
|
|||||||
export { createLogger, type Logger } from "./logger.js";
|
export { createLogger, type Logger } from "./logger.js";
|
||||||
export { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.js";
|
export { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.js";
|
||||||
export { PrMonitor, type PrComment, type TrackedPr, type OnNewCommentsCallback } from "./pr-monitor.js";
|
export { PrMonitor, type PrComment, type TrackedPr, type OnNewCommentsCallback } from "./pr-monitor.js";
|
||||||
export { PrCommentHandler } from "./pr-comment-handler.js";
|
export { NtfyNotifier, type NtfyNotifierOptions } from "./notifier.js";
|
||||||
|
|||||||
467
packages/engine/src/notifier.test.ts
Normal file
467
packages/engine/src/notifier.test.ts
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import type { Task, Column, MergeResult, Settings } from "@kb/core";
|
||||||
|
import { NtfyNotifier } from "./notifier.js";
|
||||||
|
|
||||||
|
// Mock the logger
|
||||||
|
vi.mock("./logger.js", () => ({
|
||||||
|
schedulerLog: { log: vi.fn(), error: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface MockTaskStoreEvents {
|
||||||
|
"task:moved": [{ task: Task; from: Column; to: Column }];
|
||||||
|
"task:updated": [Task];
|
||||||
|
"task:merged": [MergeResult];
|
||||||
|
"settings:updated": [{ settings: Settings; previous: Settings }];
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockTaskStore extends EventEmitter<MockTaskStoreEvents> {
|
||||||
|
private settings: Settings = {
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 15000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: true,
|
||||||
|
ntfyEnabled: false,
|
||||||
|
ntfyTopic: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
getSettings(): Settings {
|
||||||
|
return { ...this.settings };
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettings(settings: Partial<Settings>): void {
|
||||||
|
const previous = { ...this.settings };
|
||||||
|
this.settings = { ...this.settings, ...settings };
|
||||||
|
this.emit("settings:updated", { settings: this.settings, previous });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to trigger events
|
||||||
|
triggerTaskMoved(task: Task, from: Column, to: Column): void {
|
||||||
|
this.emit("task:moved", { task, from, to });
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerTaskUpdated(task: Task): void {
|
||||||
|
this.emit("task:updated", task);
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerTaskMerged(result: MergeResult): void {
|
||||||
|
this.emit("task:merged", result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("NtfyNotifier", () => {
|
||||||
|
let store: MockTaskStore;
|
||||||
|
let notifier: NtfyNotifier;
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
store = new MockTaskStore();
|
||||||
|
fetchMock = vi.fn();
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (notifier) {
|
||||||
|
notifier.stop();
|
||||||
|
}
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTask = (id: string, title?: string, status?: string): Task => ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description: "Test task",
|
||||||
|
column: "in-progress",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
status,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
log: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when disabled", () => {
|
||||||
|
it("does not send any notifications when ntfyEnabled is false", async () => {
|
||||||
|
store.setSettings({ ntfyEnabled: false, ntfyTopic: "my-topic" });
|
||||||
|
notifier = new NtfyNotifier(store);
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||||
|
|
||||||
|
// Wait for any async operations
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not send notifications when ntfyTopic is not set", async () => {
|
||||||
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: undefined });
|
||||||
|
notifier = new NtfyNotifier(store);
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when enabled", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||||
|
fetchMock.mockResolvedValue({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends notification when task moves to in-review", async () => {
|
||||||
|
notifier = new NtfyNotifier(store);
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"https://ntfy.sh/test-topic",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
"Title": "Task KB-001 completed",
|
||||||
|
"Priority": "default",
|
||||||
|
}),
|
||||||
|
body: 'Task "Test Task" is ready for review',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends notification when task moves to done", async () => {
|
||||||
|
notifier = new NtfyNotifier(store);
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-review", "done");
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"https://ntfy.sh/test-topic",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
"Title": "Task KB-001 merged",
|
||||||
|
"Priority": "default",
|
||||||
|
}),
|
||||||
|
body: 'Task "Test Task" has been merged to main',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends high priority notification when task fails", async () => {
|
||||||
|
notifier = new NtfyNotifier(store);
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
const failedTask = createTask("KB-001", "Test Task", "failed");
|
||||||
|
store.triggerTaskUpdated(failedTask);
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"https://ntfy.sh/test-topic",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
"Title": "Task KB-001 failed",
|
||||||
|
"Priority": "high",
|
||||||
|
}),
|
||||||
|
body: 'Task "Test Task" has failed and needs attention',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends notification when task is merged", async () => {
|
||||||
|
notifier = new NtfyNotifier(store);
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
const mergeResult: MergeResult = {
|
||||||
|
task: createTask("KB-001", "Test Task"),
|
||||||
|
branch: "kb/kb-001",
|
||||||
|
merged: true,
|
||||||
|
worktreeRemoved: true,
|
||||||
|
branchDeleted: true,
|
||||||
|
};
|
||||||
|
store.triggerTaskMerged(mergeResult);
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"https://ntfy.sh/test-topic",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
"Title": "Task KB-001 merged",
|
||||||
|
"Priority": "default",
|
||||||
|
}),
|
||||||
|
body: 'Task "Test Task" has been merged to main',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not send notification for failed merges", async () => {
|
||||||
|
notifier = new NtfyNotifier(store);
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
const mergeResult: MergeResult = {
|
||||||
|
task: createTask("KB-001", "Test Task"),
|
||||||
|
branch: "kb/kb-001",
|
||||||
|
merged: false,
|
||||||
|
worktreeRemoved: false,
|
||||||
|
branchDeleted: false,
|
||||||
|
error: "Merge conflict",
|
||||||
|
};
|
||||||
|
store.triggerTaskMerged(mergeResult);
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses task ID when title is not available", async () => {
|
||||||
|
notifier = new NtfyNotifier(store);
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
store.triggerTaskMoved(createTask("KB-001"), "in-progress", "in-review");
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"https://ntfy.sh/test-topic",
|
||||||
|
expect.objectContaining({
|
||||||
|
body: 'Task "KB-001" is ready for review',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runtime reconfiguration", () => {
|
||||||
|
it("starts sending notifications when enabled at runtime", async () => {
|
||||||
|
store.setSettings({ ntfyEnabled: false, ntfyTopic: "test-topic" });
|
||||||
|
notifier = new NtfyNotifier(store);
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
// Initially disabled
|
||||||
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Enable at runtime
|
||||||
|
fetchMock.mockResolvedValue({ ok: true });
|
||||||
|
store.setSettings({ ntfyEnabled: true });
|
||||||
|
|
||||||
|
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "in-progress", "in-review");
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops sending notifications when disabled at runtime", async () => {
|
||||||
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||||
|
fetchMock.mockResolvedValue({ ok: true });
|
||||||
|
notifier = new NtfyNotifier(store);
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
// Initially enabled
|
||||||
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Disable at runtime
|
||||||
|
store.setSettings({ ntfyEnabled: false });
|
||||||
|
|
||||||
|
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "in-progress", "in-review");
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1); // No new calls
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses updated topic when changed at runtime", async () => {
|
||||||
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "old-topic" });
|
||||||
|
fetchMock.mockResolvedValue({ ok: true });
|
||||||
|
notifier = new NtfyNotifier(store);
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/old-topic", expect.any(Object));
|
||||||
|
|
||||||
|
// Change topic
|
||||||
|
store.setSettings({ ntfyTopic: "new-topic" });
|
||||||
|
|
||||||
|
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "in-progress", "in-review");
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/new-topic", expect.any(Object));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("error handling", () => {
|
||||||
|
it("catches and logs fetch errors without throwing", async () => {
|
||||||
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||||
|
fetchMock.mockRejectedValue(new Error("Network error"));
|
||||||
|
|
||||||
|
notifier = new NtfyNotifier(store);
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles HTTP error responses without throwing", async () => {
|
||||||
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||||
|
fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: "Server Error" });
|
||||||
|
|
||||||
|
notifier = new NtfyNotifier(store);
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("debouncing", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||||
|
fetchMock.mockResolvedValue({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents duplicate notifications within debounce window", async () => {
|
||||||
|
notifier = new NtfyNotifier(store);
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
const task = createTask("KB-001", "Test Task");
|
||||||
|
|
||||||
|
// Rapid transitions
|
||||||
|
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||||
|
store.triggerTaskMoved(task, "in-review", "done");
|
||||||
|
store.triggerTaskMoved(task, "done", "in-review");
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
// Should only send one notification due to debouncing
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows notifications after debounce window", async () => {
|
||||||
|
notifier = new NtfyNotifier(store);
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
const task = createTask("KB-001", "Test Task");
|
||||||
|
|
||||||
|
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Wait for debounce window (5 seconds) - use fake timers or access internal state
|
||||||
|
// For this test, we'll create a new task to verify separate tasks aren't debounced together
|
||||||
|
const task2 = createTask("KB-002", "Test Task 2");
|
||||||
|
store.triggerTaskMoved(task2, "in-progress", "in-review");
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
// Different task ID should get its own notification
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("custom base URL", () => {
|
||||||
|
it("uses custom ntfy base URL when provided", async () => {
|
||||||
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||||
|
fetchMock.mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
notifier = new NtfyNotifier(store, { ntfyBaseUrl: "https://my-ntfy.example.com" });
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"https://my-ntfy.example.com/test-topic",
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stop()", () => {
|
||||||
|
it("stops listening to events after stop() is called", async () => {
|
||||||
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||||
|
fetchMock.mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
notifier = new NtfyNotifier(store);
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
notifier.stop();
|
||||||
|
|
||||||
|
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "in-progress", "in-review");
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
// Should not increase after stop
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("does not notify on task:moved to columns other than in-review or done", async () => {
|
||||||
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||||
|
fetchMock.mockResolvedValue({ ok: true });
|
||||||
|
notifier = new NtfyNotifier(store);
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
// Move to todo - should not notify
|
||||||
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "triage", "todo");
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
// Move to in-progress - should not notify
|
||||||
|
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "todo", "in-progress");
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify on task:updated when status is not failed", async () => {
|
||||||
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||||
|
fetchMock.mockResolvedValue({ ok: true });
|
||||||
|
notifier = new NtfyNotifier(store);
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
const task = createTask("KB-001", "Test Task", "in-progress");
|
||||||
|
store.triggerTaskUpdated(task);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty topic gracefully", async () => {
|
||||||
|
store.setSettings({ ntfyEnabled: true, ntfyTopic: "" });
|
||||||
|
fetchMock.mockResolvedValue({ ok: true });
|
||||||
|
notifier = new NtfyNotifier(store);
|
||||||
|
await notifier.start();
|
||||||
|
|
||||||
|
store.triggerTaskMoved(createTask("KB-001", "Test Task"), "in-progress", "in-review");
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
// Empty topic should be treated as no topic
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
238
packages/engine/src/notifier.ts
Normal file
238
packages/engine/src/notifier.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import type { TaskStore, Task, Column, Settings, MergeResult } from "@kb/core";
|
||||||
|
import { schedulerLog } from "./logger.js";
|
||||||
|
|
||||||
|
export interface NtfyNotifierOptions {
|
||||||
|
/** Base URL for ntfy.sh. Default: https://ntfy.sh */
|
||||||
|
ntfyBaseUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NtfyConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
topic: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NtfyNotifier sends push notifications via ntfy.sh when tasks complete
|
||||||
|
* or fail. It listens to TaskStore events and sends HTTP POST requests
|
||||||
|
* to the configured ntfy topic.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Runtime reconfiguration via settings:updated events
|
||||||
|
* - Best-effort delivery (errors are logged but never thrown)
|
||||||
|
* - Duplicate prevention for rapid column transitions
|
||||||
|
* - Configurable notification events (hardcoded defaults)
|
||||||
|
*/
|
||||||
|
export class NtfyNotifier {
|
||||||
|
private config: NtfyConfig = { enabled: false, topic: undefined };
|
||||||
|
private ntfyBaseUrl: string;
|
||||||
|
/** Tracks last notification time per task to prevent duplicates */
|
||||||
|
private lastNotificationTime: Map<string, number> = new Map();
|
||||||
|
/** Minimum interval between notifications for the same task (ms) */
|
||||||
|
private debounceMs = 5000;
|
||||||
|
/** AbortController for in-flight requests during shutdown */
|
||||||
|
private abortController: AbortController | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private store: TaskStore,
|
||||||
|
options: NtfyNotifierOptions = {},
|
||||||
|
) {
|
||||||
|
this.ntfyBaseUrl = options.ntfyBaseUrl ?? "https://ntfy.sh";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start listening to store events.
|
||||||
|
* Must be called after store is initialized.
|
||||||
|
* Returns a promise that resolves when initial config is loaded.
|
||||||
|
*/
|
||||||
|
async start(): Promise<void> {
|
||||||
|
this.abortController = new AbortController();
|
||||||
|
|
||||||
|
// Load initial config
|
||||||
|
const settings = await this.store.getSettings();
|
||||||
|
this.loadConfig(settings);
|
||||||
|
|
||||||
|
// Listen for task movements
|
||||||
|
this.store.on("task:moved", this.handleTaskMoved);
|
||||||
|
|
||||||
|
// Listen for task updates (status changes)
|
||||||
|
this.store.on("task:updated", this.handleTaskUpdated);
|
||||||
|
|
||||||
|
// Listen for merge events
|
||||||
|
this.store.on("task:merged", this.handleTaskMerged);
|
||||||
|
|
||||||
|
// Listen for settings changes for runtime reconfiguration
|
||||||
|
this.store.on("settings:updated", this.handleSettingsUpdated);
|
||||||
|
|
||||||
|
schedulerLog.log("NtfyNotifier started");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop listening to store events and abort in-flight requests.
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
this.store.off("task:moved", this.handleTaskMoved);
|
||||||
|
this.store.off("task:updated", this.handleTaskUpdated);
|
||||||
|
this.store.off("task:merged", this.handleTaskMerged);
|
||||||
|
this.store.off("settings:updated", this.handleSettingsUpdated);
|
||||||
|
|
||||||
|
// Abort any in-flight requests
|
||||||
|
if (this.abortController) {
|
||||||
|
this.abortController.abort();
|
||||||
|
this.abortController = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
schedulerLog.log("NtfyNotifier stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleTaskMoved = (data: { task: Task; from: Column; to: Column }): void => {
|
||||||
|
if (!this.config.enabled || !this.config.topic) return;
|
||||||
|
|
||||||
|
const { task, to } = data;
|
||||||
|
|
||||||
|
// Notify when task moves to in-review (completed work, ready for review)
|
||||||
|
if (to === "in-review") {
|
||||||
|
this.maybeNotify(task.id, () =>
|
||||||
|
this.sendNotification(
|
||||||
|
this.config.topic!,
|
||||||
|
`Task ${task.id} completed`,
|
||||||
|
`Task "${task.title ?? task.id}" is ready for review`,
|
||||||
|
"default",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify when task moves to done (merged to main)
|
||||||
|
if (to === "done") {
|
||||||
|
this.maybeNotify(task.id, () =>
|
||||||
|
this.sendNotification(
|
||||||
|
this.config.topic!,
|
||||||
|
`Task ${task.id} merged`,
|
||||||
|
`Task "${task.title ?? task.id}" has been merged to main`,
|
||||||
|
"default",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleTaskUpdated = (task: Task): void => {
|
||||||
|
if (!this.config.enabled || !this.config.topic) return;
|
||||||
|
|
||||||
|
// Notify when task fails
|
||||||
|
if (task.status === "failed") {
|
||||||
|
this.maybeNotify(task.id, () =>
|
||||||
|
this.sendNotification(
|
||||||
|
this.config.topic!,
|
||||||
|
`Task ${task.id} failed`,
|
||||||
|
`Task "${task.title ?? task.id}" has failed and needs attention`,
|
||||||
|
"high",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleTaskMerged = (result: MergeResult): void => {
|
||||||
|
if (!this.config.enabled || !this.config.topic) return;
|
||||||
|
|
||||||
|
// Only notify on successful merges
|
||||||
|
if (result.merged) {
|
||||||
|
this.maybeNotify(result.task.id, () =>
|
||||||
|
this.sendNotification(
|
||||||
|
this.config.topic!,
|
||||||
|
`Task ${result.task.id} merged`,
|
||||||
|
`Task "${result.task.title ?? result.task.id}" has been merged to main`,
|
||||||
|
"default",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleSettingsUpdated = (data: { settings: Settings; previous: Settings }): void => {
|
||||||
|
const { settings, previous } = data;
|
||||||
|
|
||||||
|
// Check if ntfy settings changed
|
||||||
|
if (settings.ntfyEnabled !== previous.ntfyEnabled ||
|
||||||
|
settings.ntfyTopic !== previous.ntfyTopic) {
|
||||||
|
const wasEnabled = this.config.enabled;
|
||||||
|
this.loadConfig(settings);
|
||||||
|
|
||||||
|
if (this.config.enabled && !wasEnabled) {
|
||||||
|
schedulerLog.log("NtfyNotifier enabled");
|
||||||
|
} else if (!this.config.enabled && wasEnabled) {
|
||||||
|
schedulerLog.log("NtfyNotifier disabled");
|
||||||
|
} else if (this.config.topic !== previous.ntfyTopic) {
|
||||||
|
schedulerLog.log("NtfyNotifier topic updated");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private loadConfig(settings: Settings): void {
|
||||||
|
this.config = {
|
||||||
|
enabled: settings.ntfyEnabled ?? false,
|
||||||
|
topic: settings.ntfyTopic,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notification if enough time has passed since last notification for this task.
|
||||||
|
* This prevents duplicate notifications during rapid column transitions.
|
||||||
|
*/
|
||||||
|
private maybeNotify(taskId: string, notifyFn: () => Promise<void>): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const lastTime = this.lastNotificationTime.get(taskId);
|
||||||
|
|
||||||
|
if (lastTime && now - lastTime < this.debounceMs) {
|
||||||
|
// Too soon, skip this notification
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastNotificationTime.set(taskId, now);
|
||||||
|
notifyFn().catch(() => {
|
||||||
|
// Errors are logged in sendNotification, just need to catch here
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a notification to ntfy.sh.
|
||||||
|
* Errors are caught and logged, never thrown.
|
||||||
|
*/
|
||||||
|
private async sendNotification(
|
||||||
|
topic: string,
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
priority: "low" | "default" | "high" | "urgent" = "default",
|
||||||
|
): Promise<void> {
|
||||||
|
const url = `${this.ntfyBaseUrl}/${topic}`;
|
||||||
|
const signal = this.abortController?.signal;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Title": title,
|
||||||
|
"Priority": priority,
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
body: message,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
schedulerLog.log(`Ntfy notification failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Don't throw - notifications are best-effort
|
||||||
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
|
// Expected during shutdown
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schedulerLog.log(`Failed to send ntfy notification: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current config (for testing purposes).
|
||||||
|
*/
|
||||||
|
getConfig(): NtfyConfig {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user