diff --git a/SEARCH_FEATURE.md b/SEARCH_FEATURE.md new file mode 100644 index 0000000..07156fc --- /dev/null +++ b/SEARCH_FEATURE.md @@ -0,0 +1,99 @@ +# Gantt Board Search Feature + +## Overview +Enhanced search functionality for the Mission Control Gantt Board with real-time filtering, text highlighting, and keyboard shortcuts. + +## Features + +### 1. Search Bar Component +- **Location:** `src/components/SearchBar.tsx` +- **Features:** + - Clean, modern UI with search icon + - Clear button (X) when query exists + - Keyboard shortcut hint (⌘K / Ctrl+K) + - Escape to clear and blur + - Mobile responsive + +### 2. Keyboard Shortcuts +- **⌘K / Ctrl+K:** Focus search input +- **Escape:** Clear search (if text exists) or blur input + +### 3. Highlighting Components +- **Location:** `src/components/HighlightText.tsx` +- **Features:** + - Highlights matching text in yellow (`bg-yellow-500/30`) + - Case-insensitive matching + - Works with titles, descriptions, and tags + - `HighlightMatches` helper for highlighting multiple fields + +### 4. Search Scope +Search covers: +- ✅ Task titles +- ✅ Task descriptions +- ✅ Tags +- ✅ Assignee names (in Search view) +- ✅ Status and type (in Search view) + +### 5. Real-time Filtering +- 300ms debounce for smooth performance +- Instant filter updates in Kanban, Backlog, and Search views +- Search results count displayed + +### 6. Task Highlighting +In Kanban view: +- Task titles are highlighted when they match +- Matching tags get yellow border and background +- Non-matching tags remain muted + +## Usage + +### Basic Search +1. Type in the search box or press ⌘K to focus +2. Tasks filter automatically as you type +3. Click X or press Escape to clear + +### View Switching +- Search automatically switches to "Search" view when you type +- Toggle between Kanban/Backlog/Search views using the view buttons +- Each view respects the current search query + +## Files Modified/Created + +### New Files +- `src/components/SearchBar.tsx` - Reusable search input component +- `src/components/HighlightText.tsx` - Text highlighting utility +- `src/components/TaskSearchItem.tsx` - Search result item component + +### Modified Files +- `src/app/page.tsx` - Integrated SearchBar, added searchQuery prop to KanbanTaskCard +- `src/components/SearchView.tsx` - Uses HighlightText for better display + +## Technical Details + +### Search Algorithm +- Case-insensitive string matching +- Matches partial strings (e.g., "bug" matches "debug") +- Debounced at 300ms for performance + +### Highlight Styling +```css +/* Matching text */ +bg-yellow-500/30 - Yellow background +ring-yellow-500/30 - Yellow border on tags +text-yellow-200 - Light yellow text + +/* Non-matching */ +Regular muted styling preserved +``` + +### State Management +- `searchQuery` - Current input value (immediate) +- `debouncedSearchQuery` - Debounced value for filtering (300ms delay) + +## Future Enhancements +Potential improvements: +- [ ] Advanced filters (by status, assignee, date range) +- [ ] Saved searches +- [ ] Search history +- [ ] Full-text search in task comments +- [ ] Fuzzy/partial matching improvements diff --git a/scripts/gantt.sh b/scripts/gantt.sh index 81520c2..de771cb 100755 --- a/scripts/gantt.sh +++ b/scripts/gantt.sh @@ -36,11 +36,56 @@ check_deps() { fi } -# API call helper +# Machine-to-machine API call (for cron/automation) +api_call_machine() { + local method="$1" + local endpoint="$2" + local data="${3:-}" + + local token="${GANTT_MACHINE_TOKEN:-}" + if [ -z "$token" ]; then + log_error "GANTT_MACHINE_TOKEN not set" + return 1 + fi + + local url="${API_URL}${endpoint}" + local curl_opts=(-s -w "\n%{http_code}" -H "Content-Type: application/json" -H "Authorization: Bearer ${token}") + + if [ -n "$data" ]; then + curl_opts+=(-d "$data") + fi + + local response + response=$(curl "${curl_opts[@]}" -X "$method" "$url") + + local http_code + http_code=$(echo "$response" | tail -n1) + local body + body=$(echo "$response" | sed '$d') + + if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then + echo "$body" + return 0 + else + log_error "API call failed (HTTP $http_code)" + echo "$body" | jq '.' 2>/dev/null || echo "$body" + return 1 + fi +} + +# API call helper (cookie auth for interactive, machine token for automation) api_call() { local method="$1" local endpoint="$2" local data="${3:-}" + + # Machine token path for automation/cron + if [ -n "${GANTT_MACHINE_TOKEN:-}" ]; then + api_call_machine "$method" "$endpoint" "$data" + return $? + fi + + # Cookie auth path for interactive use local url="${API_URL}${endpoint}" mkdir -p "$(dirname "$COOKIE_FILE")" diff --git a/scripts/lib/api_client.sh b/scripts/lib/api_client.sh index 80712cb..8223b5f 100755 --- a/scripts/lib/api_client.sh +++ b/scripts/lib/api_client.sh @@ -45,11 +45,57 @@ login_if_needed() { fi } +# Machine-to-machine API call (for cron/automation) +# Uses GANTT_MACHINE_TOKEN env var instead of cookie auth +api_call_machine() { + local method="$1" + local endpoint="$2" + local data="${3:-}" + + local token="${GANTT_MACHINE_TOKEN:-}" + if [[ -z "$token" ]]; then + echo "Error: GANTT_MACHINE_TOKEN not set" >&2 + return 1 + fi + + local url="${API_URL}${endpoint}" + local curl_opts=(-sS -w "\n%{http_code}" -X "$method" "$url" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${token}") + + if [[ -n "$data" ]]; then + curl_opts+=(--data "$data") + fi + + local response + response=$(curl "${curl_opts[@]}") || return 1 + + local http_code + http_code=$(echo "$response" | tail -n1) + local body + body=$(echo "$response" | sed '$d') + + if [[ "$http_code" =~ ^2[0-9][0-9]$ ]]; then + echo "$body" + return 0 + fi + + echo "API request failed ($method $endpoint) HTTP $http_code" >&2 + echo "$body" | jq . 2>/dev/null >&2 || echo "$body" >&2 + return 1 +} + api_call() { local method="$1" local endpoint="$2" local data="${3:-}" + # Machine token path for automation/cron (no cookie auth needed) + if [[ -n "${GANTT_MACHINE_TOKEN:-}" ]]; then + api_call_machine "$method" "$endpoint" "$data" + return $? + fi + ensure_cookie_store login_if_needed || return 1 diff --git a/scripts/task.sh b/scripts/task.sh index dacc1e7..79734cc 100755 --- a/scripts/task.sh +++ b/scripts/task.sh @@ -506,7 +506,22 @@ update_task() { existing=$(echo "$existing" | jq --argjson c "$comment" '.comments = (.comments // []) + [$c]') fi - api_call POST "/tasks" "{\"task\": $existing}" | jq . + # Use temporary file for large payloads to avoid "Argument list too long" errors + local temp_file + temp_file=$(mktemp) + echo "{\"task\": $existing}" > "$temp_file" + + # Use curl directly with --data @file to avoid argument length limits + local response + response=$(curl -sS -X POST "${API_URL}/tasks" \ + -H "Content-Type: application/json" \ + -b "$COOKIE_FILE" -c "$COOKIE_FILE" \ + --data @"$temp_file") + + # Clean up temp file + rm -f "$temp_file" + + echo "$response" | jq . } delete_task() { diff --git a/scripts/task.sh.backup b/scripts/task.sh.backup new file mode 100755 index 0000000..dacc1e7 --- /dev/null +++ b/scripts/task.sh.backup @@ -0,0 +1,600 @@ +#!/bin/bash +# Task CLI for Gantt Board (API passthrough) +# Usage: ./task.sh [list|get|create|update|delete|current-sprint|bulk-create] [args...] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./lib/api_client.sh +source "$SCRIPT_DIR/lib/api_client.sh" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { echo -e "${BLUE}i${NC} $1"; } +log_success() { echo -e "${GREEN}ok${NC} $1"; } +log_warning() { echo -e "${YELLOW}warn${NC} $1"; } +log_error() { echo -e "${RED}error${NC} $1"; } + +UUID_PATTERN='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' +DEFAULT_PROJECT_ID="${DEFAULT_PROJECT_ID:-}" +DEFAULT_ASSIGNEE_ID="${DEFAULT_ASSIGNEE_ID:-}" + +check_dependencies() { + if ! command -v jq >/dev/null 2>&1; then + log_error "jq is required. Install with: brew install jq" + exit 1 + fi + if ! command -v uuidgen >/dev/null 2>&1; then + log_error "uuidgen is required" + exit 1 + fi +} + +show_usage() { + cat << 'USAGE' +Task CLI for Gantt Board + +USAGE: + ./task.sh [list|get|create|update|delete|current-sprint|bulk-create] [args...] + +COMMANDS: + list [status] List tasks (supports filters) + get Get specific task + create "Title" [status] [priority] [project] [assignee] [sprint] [type] + create --title "Title" [flags...] Create task using flags + update Update one field (legacy) + update [flags...] Update task with flags + delete Delete task + current-sprint Show current sprint ID + bulk-create Bulk create from JSON array +USAGE +} + +resolve_project_id() { + local identifier="$1" + + if [[ -z "$identifier" ]]; then + if [[ -n "$DEFAULT_PROJECT_ID" ]]; then + echo "$DEFAULT_PROJECT_ID" + return 0 + fi + local response + response=$(api_call GET "/projects") + echo "$response" | jq -r '.projects[0].id // empty' + return 0 + fi + + if [[ "$identifier" =~ $UUID_PATTERN ]]; then + echo "$identifier" + return 0 + fi + + local response + response=$(api_call GET "/projects") + local project_id + project_id=$(echo "$response" | jq -r --arg q "$identifier" ' + .projects + | map(select((.name // "") | ascii_downcase | contains($q | ascii_downcase))) + | .[0].id // empty + ') + + if [[ -n "$project_id" ]]; then + echo "$project_id" + return 0 + fi + + log_error "Project '$identifier' not found" + return 1 +} + +resolve_assignee_id() { + local identifier="$1" + + if [[ -z "$identifier" ]]; then + if [[ -n "$DEFAULT_ASSIGNEE_ID" ]]; then + echo "$DEFAULT_ASSIGNEE_ID" + else + echo "" + fi + return 0 + fi + + if [[ "$identifier" =~ $UUID_PATTERN ]]; then + echo "$identifier" + return 0 + fi + + local response + response=$(api_call GET "/auth/users") + local user_id + user_id=$(echo "$response" | jq -r --arg q "$identifier" ' + .users + | map(select( + ((.name // "") | ascii_downcase | contains($q | ascii_downcase)) + or ((.email // "") | ascii_downcase == ($q | ascii_downcase)) + )) + | .[0].id // empty + ') + + if [[ -n "$user_id" ]]; then + echo "$user_id" + return 0 + fi + + log_error "Assignee '$identifier' not found" + return 1 +} + +resolve_sprint_id() { + local identifier="$1" + + if [[ -z "$identifier" ]]; then + echo "" + return 0 + fi + + if [[ "$identifier" == "current" ]]; then + local response + response=$(api_call GET "/sprints/current") + echo "$response" | jq -r '.sprint.id // empty' + return 0 + fi + + if [[ "$identifier" =~ $UUID_PATTERN ]]; then + echo "$identifier" + return 0 + fi + + local response + response=$(api_call GET "/sprints") + local sprint_id + sprint_id=$(echo "$response" | jq -r --arg q "$identifier" ' + .sprints + | map(select((.name // "") | ascii_downcase | contains($q | ascii_downcase))) + | .[0].id // empty + ') + + if [[ -n "$sprint_id" ]]; then + echo "$sprint_id" + return 0 + fi + + log_error "Sprint '$identifier' not found" + return 1 +} + +get_current_sprint() { + local response + response=$(api_call GET "/sprints/current") + echo "$response" | jq -r '.sprint.id // empty' +} + +to_tag_array() { + local tags_csv="$1" + if [[ -z "$tags_csv" ]]; then + echo '[]' + return + fi + jq -cn --arg csv "$tags_csv" '$csv | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))' +} + +to_comment_array() { + local comment_text="$1" + if [[ -z "$comment_text" ]]; then + echo '[]' + return + fi + local now + now=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + jq -cn --arg id "$(date +%s)-$RANDOM" --arg text "$comment_text" --arg createdAt "$now" ' + [{ id: $id, text: $text, createdAt: $createdAt, commentAuthorId: "assistant", replies: [] }] + ' +} + +list_tasks() { + local positional_status="${1:-}" + local status_filter="" + local priority_filter="" + local project_filter="" + local assignee_filter="" + local type_filter="" + local limit="" + local output_json=false + + if [[ -n "$positional_status" && "$positional_status" != --* ]]; then + status_filter="$positional_status" + shift || true + fi + + while [[ $# -gt 0 ]]; do + case "${1:-}" in + --status) status_filter="${2:-}"; shift 2 ;; + --priority) priority_filter="${2:-}"; shift 2 ;; + --project) project_filter="${2:-}"; shift 2 ;; + --assignee) assignee_filter="${2:-}"; shift 2 ;; + --type) type_filter="${2:-}"; shift 2 ;; + --limit) limit="${2:-}"; shift 2 ;; + --json) output_json=true; shift ;; + *) shift ;; + esac + done + + local response + response=$(api_call GET "/tasks?scope=active-sprint") + + local tasks + tasks=$(echo "$response" | jq '.tasks') + + if [[ -n "$status_filter" ]]; then + tasks=$(echo "$tasks" | jq --arg v "$status_filter" ' + ($v | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $statuses + | map(select(.status as $s | ($statuses | index($s)))) + ') + fi + if [[ -n "$priority_filter" ]]; then + tasks=$(echo "$tasks" | jq --arg v "$priority_filter" 'map(select(.priority == $v))') + fi + if [[ -n "$type_filter" ]]; then + tasks=$(echo "$tasks" | jq --arg v "$type_filter" 'map(select(.type == $v))') + fi + if [[ -n "$project_filter" ]]; then + local project_id + project_id=$(resolve_project_id "$project_filter") + tasks=$(echo "$tasks" | jq --arg v "$project_id" 'map(select(.projectId == $v))') + fi + if [[ -n "$assignee_filter" ]]; then + local assignee_id + assignee_id=$(resolve_assignee_id "$assignee_filter") + tasks=$(echo "$tasks" | jq --arg v "$assignee_id" 'map(select(.assigneeId == $v))') + fi + if [[ -n "$limit" ]]; then + tasks=$(echo "$tasks" | jq --argjson n "$limit" '.[0:$n]') + fi + + if [[ "$output_json" == true ]]; then + echo "$tasks" | jq . + return + fi + + local count + count=$(echo "$tasks" | jq 'length') + if [[ "$count" -eq 0 ]]; then + local current_sprint_id + current_sprint_id="$(get_current_sprint 2>/dev/null || true)" + if [[ -z "$current_sprint_id" ]]; then + log_warning "No current sprint found. Returning 0 tasks." + fi + fi + log_success "Found $count task(s)" + + printf "%-36s %-34s %-12s %-10s\n" "ID" "TITLE" "STATUS" "PRIORITY" + printf "%-36s %-34s %-12s %-10s\n" "------------------------------------" "----------------------------------" "------------" "----------" + + echo "$tasks" | jq -r '.[] | [.id, (.title // "" | tostring | .[0:32]), (.status // ""), (.priority // "")] | @tsv' \ + | while IFS=$'\t' read -r id title status priority; do + printf "%-36s %-34s %-12s %-10s\n" "$id" "$title" "$status" "$priority" + done +} + +get_task() { + local task_id="${1:-}" + if [[ -z "$task_id" ]]; then + log_error "Task ID required" + exit 1 + fi + + local response + response=$(api_call GET "/tasks?taskId=$(urlencode "$task_id")&include=detail") + echo "$response" | jq '.tasks[0]' +} + +create_from_payload() { + local task_payload="$1" + api_call POST "/tasks" "{\"task\": $task_payload}" | jq . +} + +create_task() { + local title="" + local description="" + local task_type="task" + local status="open" + local priority="medium" + local project="" + local sprint="" + local assignee="" + local due_date="" + local tags_csv="" + local comments_text="" + local file_input="" + + if [[ $# -gt 0 && "${1:-}" != --* ]]; then + # Legacy positional form + title="${1:-}" + status="${2:-$status}" + priority="${3:-$priority}" + project="${4:-$project}" + assignee="${5:-$assignee}" + sprint="${6:-$sprint}" + task_type="${7:-$task_type}" + fi + + while [[ $# -gt 0 ]]; do + case "${1:-}" in + --title) title="${2:-}"; shift 2 ;; + --description) description="${2:-}"; shift 2 ;; + --type) task_type="${2:-}"; shift 2 ;; + --status) status="${2:-}"; shift 2 ;; + --priority) priority="${2:-}"; shift 2 ;; + --project) project="${2:-}"; shift 2 ;; + --sprint) sprint="${2:-}"; shift 2 ;; + --assignee) assignee="${2:-}"; shift 2 ;; + --due-date) due_date="${2:-}"; shift 2 ;; + --tags) tags_csv="${2:-}"; shift 2 ;; + --comments) comments_text="${2:-}"; shift 2 ;; + --file) file_input="${2:-}"; shift 2 ;; + --interactive) + log_error "Interactive mode is not supported in API passthrough mode" + exit 1 + ;; + *) shift ;; + esac + done + + if [[ -n "$file_input" ]]; then + if [[ ! -f "$file_input" ]]; then + log_error "File not found: $file_input" + exit 1 + fi + title=$(jq -r '.title // ""' "$file_input") + description=$(jq -r '.description // ""' "$file_input") + task_type=$(jq -r '.type // "task"' "$file_input") + status=$(jq -r '.status // "open"' "$file_input") + priority=$(jq -r '.priority // "medium"' "$file_input") + project=$(jq -r '.project // ""' "$file_input") + sprint=$(jq -r '.sprint // ""' "$file_input") + assignee=$(jq -r '.assignee // ""' "$file_input") + due_date=$(jq -r '.due_date // ""' "$file_input") + tags_csv=$(jq -r '.tags // ""' "$file_input") + comments_text=$(jq -r '.comments // ""' "$file_input") + fi + + if [[ -z "$title" ]]; then + log_error "Title is required" + exit 1 + fi + + local project_id + project_id=$(resolve_project_id "$project") + if [[ -z "$project_id" ]]; then + log_error "Could not resolve project" + exit 1 + fi + + local assignee_id + assignee_id=$(resolve_assignee_id "$assignee") + + local sprint_id + sprint_id=$(resolve_sprint_id "$sprint") + + local tags_json + tags_json=$(to_tag_array "$tags_csv") + + local comments_json + comments_json=$(to_comment_array "$comments_text") + + local task_id + task_id=$(uuidgen | tr '[:upper:]' '[:lower:]') + local now + now=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + local task_payload + task_payload=$(jq -n \ + --arg id "$task_id" \ + --arg title "$title" \ + --arg description "$description" \ + --arg type "$task_type" \ + --arg status "$status" \ + --arg priority "$priority" \ + --arg projectId "$project_id" \ + --arg sprintId "$sprint_id" \ + --arg assigneeId "$assignee_id" \ + --arg dueDate "$due_date" \ + --arg createdAt "$now" \ + --arg updatedAt "$now" \ + --argjson tags "$tags_json" \ + --argjson comments "$comments_json" \ + '{ + id: $id, + title: $title, + description: (if $description == "" then null else $description end), + type: $type, + status: $status, + priority: $priority, + projectId: $projectId, + sprintId: (if $sprintId == "" then null else $sprintId end), + assigneeId: (if $assigneeId == "" then null else $assigneeId end), + dueDate: (if $dueDate == "" then null else $dueDate end), + createdAt: $createdAt, + updatedAt: $updatedAt, + tags: $tags, + comments: $comments, + attachments: [] + }') + + create_from_payload "$task_payload" +} + +update_task() { + local task_id="${1:-}" + shift || true + + if [[ -z "$task_id" ]]; then + log_error "Task ID required" + exit 1 + fi + + local existing + existing=$(api_call GET "/tasks?taskId=$(urlencode "$task_id")&include=detail" | jq '.tasks[0]') + + if [[ "$existing" == "null" || -z "$existing" ]]; then + log_error "Task not found: $task_id" + exit 1 + fi + + if [[ $# -ge 2 && "${1:-}" != --* ]]; then + local field="${1:-}" + local value="${2:-}" + existing=$(echo "$existing" | jq --arg f "$field" --arg v "$value" '. + {($f): $v}') + shift 2 || true + fi + + local add_comment="" + local clear_tags=false + local set_tags="" + + while [[ $# -gt 0 ]]; do + case "${1:-}" in + --status) existing=$(echo "$existing" | jq --arg v "${2:-}" '.status = $v'); shift 2 ;; + --priority) existing=$(echo "$existing" | jq --arg v "${2:-}" '.priority = $v'); shift 2 ;; + --title) existing=$(echo "$existing" | jq --arg v "${2:-}" '.title = $v'); shift 2 ;; + --description) existing=$(echo "$existing" | jq --arg v "${2:-}" '.description = $v'); shift 2 ;; + --type) existing=$(echo "$existing" | jq --arg v "${2:-}" '.type = $v'); shift 2 ;; + --project) + local project_id + project_id=$(resolve_project_id "${2:-}") + existing=$(echo "$existing" | jq --arg v "$project_id" '.projectId = $v') + shift 2 + ;; + --assignee) + local assignee_id + assignee_id=$(resolve_assignee_id "${2:-}") + existing=$(echo "$existing" | jq --arg v "$assignee_id" '.assigneeId = (if $v == "" then null else $v end)') + shift 2 + ;; + --sprint) + local sprint_input="${2:-}" + local sprint_id + sprint_id=$(resolve_sprint_id "$sprint_input") + existing=$(echo "$existing" | jq --arg v "$sprint_id" '.sprintId = (if $v == "" then null else $v end)') + shift 2 + ;; + --due-date) existing=$(echo "$existing" | jq --arg v "${2:-}" '.dueDate = (if $v == "" then null else $v end)'); shift 2 ;; + --add-comment) add_comment="${2:-}"; shift 2 ;; + --clear-tags) clear_tags=true; shift ;; + --tags) set_tags="${2:-}"; shift 2 ;; + *) shift ;; + esac + done + + if [[ "$clear_tags" == true ]]; then + existing=$(echo "$existing" | jq '.tags = []') + fi + + if [[ -n "$set_tags" ]]; then + local tags_json + tags_json=$(to_tag_array "$set_tags") + existing=$(echo "$existing" | jq --argjson tags "$tags_json" '.tags = $tags') + fi + + if [[ -n "$add_comment" ]]; then + local comment + comment=$(to_comment_array "$add_comment" | jq '.[0]') + existing=$(echo "$existing" | jq --argjson c "$comment" '.comments = (.comments // []) + [$c]') + fi + + api_call POST "/tasks" "{\"task\": $existing}" | jq . +} + +delete_task() { + local task_id="${1:-}" + if [[ -z "$task_id" ]]; then + log_error "Task ID required" + exit 1 + fi + api_call DELETE "/tasks" "{\"id\": \"$task_id\"}" | jq . +} + +bulk_create() { + local file="${1:-}" + if [[ -z "$file" || ! -f "$file" ]]; then + log_error "bulk-create requires a valid JSON file" + exit 1 + fi + + jq -c '.[]' "$file" | while IFS= read -r row; do + local title + title=$(echo "$row" | jq -r '.title // empty') + if [[ -z "$title" ]]; then + log_warning "Skipping entry without title" + continue + fi + + local task_type status priority project sprint assignee due_date tags comments description + task_type=$(echo "$row" | jq -r '.type // "task"') + status=$(echo "$row" | jq -r '.status // "open"') + priority=$(echo "$row" | jq -r '.priority // "medium"') + project=$(echo "$row" | jq -r '.project // ""') + sprint=$(echo "$row" | jq -r '.sprint // ""') + assignee=$(echo "$row" | jq -r '.assignee // ""') + due_date=$(echo "$row" | jq -r '.due_date // ""') + tags=$(echo "$row" | jq -r '.tags // ""') + comments=$(echo "$row" | jq -r '.comments // ""') + description=$(echo "$row" | jq -r '.description // ""') + + create_task \ + --title "$title" \ + --description "$description" \ + --type "$task_type" \ + --status "$status" \ + --priority "$priority" \ + --project "$project" \ + --sprint "$sprint" \ + --assignee "$assignee" \ + --due-date "$due_date" \ + --tags "$tags" \ + --comments "$comments" + done +} + +check_dependencies + +case "${1:-}" in + list) + shift + list_tasks "$@" + ;; + get) + get_task "${2:-}" + ;; + create) + shift + create_task "$@" + ;; + update) + shift + update_task "$@" + ;; + delete) + delete_task "${2:-}" + ;; + current-sprint) + shift + get_current_sprint + ;; + bulk-create) + shift + bulk_create "${1:-}" + ;; + help|--help|-h|"") + show_usage + ;; + *) + log_error "Unknown command: ${1:-}" + show_usage + exit 1 + ;; +esac diff --git a/src/app/page.tsx b/src/app/page.tsx index 0a7ba11..dcdd7a0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -37,7 +37,9 @@ import { } from "@/lib/attachments" import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment } from "@/stores/useTaskStore" import { BacklogSkeleton, SearchSkeleton } from "@/components/LoadingSkeletons" -import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download, Search, Archive, Folder } from "lucide-react" +import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download, Search, Archive, Folder, Command } from "lucide-react" +import { SearchBar } from "@/components/SearchBar" +import { HighlightText } from "@/components/HighlightText" import { toast } from "sonner" // Dynamic imports for heavy view components - only load when needed @@ -242,12 +244,14 @@ function KanbanTaskCard({ assigneeAvatarUrl, onOpen, onDelete, + searchQuery = "", }: { task: Task taskTags: string[] assigneeAvatarUrl?: string onOpen: () => void onDelete: () => void + searchQuery?: string }) { const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: task.id, @@ -298,7 +302,13 @@ function KanbanTaskCard({ -

{task.title}

+

+ {searchQuery.trim() ? ( + + ) : ( + task.title + )} +

{task.description && (

{task.description} @@ -344,14 +354,21 @@ function KanbanTaskCard({ {taskTags.length > 0 && (

- {taskTags.map((tag) => ( - - {tag} - - ))} + {taskTags.map((tag) => { + const isMatch = searchQuery.trim() && tag.toLowerCase().includes(searchQuery.toLowerCase()) + return ( + + {isMatch ? : tag} + + ) + })}
)} @@ -1008,23 +1025,13 @@ export default function Home() {
{/* Search Input */} -
- - + setSearchQuery(e.target.value)} + onChange={setSearchQuery} placeholder="Search tasks..." - className="w-48 lg:w-64 pl-9 pr-8 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition-colors" + showKeyboardShortcut={true} /> - {searchQuery && ( - - )}
{isLoading && ( @@ -1180,24 +1187,12 @@ export default function Home() { {/* Mobile Search - shown only on small screens */}
-
- - setSearchQuery(e.target.value)} - placeholder="Search tasks..." - className="w-full pl-9 pr-8 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500" - /> - {searchQuery && ( - - )} -
+
{/* View Content */} @@ -1256,6 +1251,7 @@ export default function Home() { assigneeAvatarUrl={resolveAssignee(task.assigneeId)?.avatarUrl || task.assigneeAvatarUrl} onOpen={() => router.push(`/tasks/${encodeURIComponent(task.id)}`)} onDelete={() => deleteTask(task.id)} + searchQuery={debouncedSearchQuery} /> ))} diff --git a/src/components/HighlightText.tsx b/src/components/HighlightText.tsx new file mode 100644 index 0000000..4fba58a --- /dev/null +++ b/src/components/HighlightText.tsx @@ -0,0 +1,134 @@ +"use client" + +import React from "react" + +interface HighlightTextProps { + text: string + query: string + className?: string + highlightClassName?: string +} + +/** + * Component that highlights matching text within content + * Splits text by the query and wraps matches in a highlight span + */ +export function HighlightText({ + text, + query, + className = "", + highlightClassName = "bg-yellow-500/30 text-yellow-200 font-medium", +}: HighlightTextProps) { + if (!query.trim() || !text) { + return {text} + } + + const normalizedQuery = query.toLowerCase() + const normalizedText = text.toLowerCase() + + // Split text by query matches + const parts: { text: string; match: boolean }[] = [] + let lastIndex = 0 + let index = normalizedText.indexOf(normalizedQuery) + + while (index !== -1) { + // Add text before match + if (index > lastIndex) { + parts.push({ + text: text.slice(lastIndex, index), + match: false, + }) + } + + // Add the match + parts.push({ + text: text.slice(index, index + query.length), + match: true, + }) + + lastIndex = index + query.length + index = normalizedText.indexOf(normalizedQuery, lastIndex) + } + + // Add remaining text + if (lastIndex < text.length) { + parts.push({ + text: text.slice(lastIndex), + match: false, + }) + } + + return ( + + {parts.map((part, i) => + part.match ? ( + + {part.text} + + ) : ( + {part.text} + ) + )} + + ) +} + +/** + * Highlight multiple fields, returning null if no matches found + * Useful for conditionally highlighting description, tags, etc. + */ +interface HighlightMatch { + field: string + value: string +} + +interface HighlightMatchesProps { + matches: HighlightMatch[] + query: string + className?: string + highlightClassName?: string + maxLength?: number +} + +export function HighlightMatches({ + matches, + query, + className = "text-sm text-slate-400", + highlightClassName = "bg-yellow-500/30 text-yellow-200 font-medium", + maxLength = 120, +}: HighlightMatchesProps) { + if (!query.trim()) return null + + const normalizedQuery = query.toLowerCase() + + // Find which matches contain the query + const matchingFields = matches.filter((match) => + match.value.toLowerCase().includes(normalizedQuery) + ) + + if (matchingFields.length === 0) return null + + return ( +
+ {matchingFields.map((match, index) => ( +
+ {match.field}: + maxLength + ? match.value.slice(0, maxLength) + "..." + : match.value + } + query={query} + highlightClassName={highlightClassName} + /> +
+ ))} +
+ ) +} + +export default HighlightText diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx new file mode 100644 index 0000000..4364807 --- /dev/null +++ b/src/components/SearchBar.tsx @@ -0,0 +1,88 @@ +"use client" + +import React, { useRef, useEffect, useCallback } from "react" +import { Search, X, Command } from "lucide-react" + +interface SearchBarProps { + value: string + onChange: (value: string) => void + placeholder?: string + className?: string + autoFocus?: boolean + showKeyboardShortcut?: boolean +} + +export function SearchBar({ + value, + onChange, + placeholder = "Search tasks...", + className = "", + autoFocus = false, + showKeyboardShortcut = true, +}: SearchBarProps) { + const inputRef = useRef(null) + + // Handle Ctrl/Cmd+K keyboard shortcut + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Check for Ctrl+K or Cmd+K + if ((e.ctrlKey || e.metaKey) && e.key === "k") { + e.preventDefault() + inputRef.current?.focus() + } + // Check for Escape to clear and blur + if (e.key === "Escape" && document.activeElement === inputRef.current) { + if (value) { + onChange("") + } else { + inputRef.current?.blur() + } + } + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [value, onChange]) + + const handleClear = useCallback(() => { + onChange("") + inputRef.current?.focus() + }, [onChange]) + + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + autoFocus={autoFocus} + className="w-full pl-9 pr-20 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors" + /> + + {/* Right side controls */} +
+ {value && ( + + )} + + {showKeyboardShortcut && !value && ( + + + K + + )} +
+
+ ) +} + +export default SearchBar diff --git a/src/components/TaskSearchItem.tsx b/src/components/TaskSearchItem.tsx new file mode 100644 index 0000000..7e51fd2 --- /dev/null +++ b/src/components/TaskSearchItem.tsx @@ -0,0 +1,223 @@ +"use client" + +import React from "react" +import { useRouter } from "next/navigation" +import { Card, CardContent } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { MessageSquare, Calendar, Paperclip, Hash } from "lucide-react" +import { Task, TaskType, TaskStatus } from "@/stores/useTaskStore" +import { HighlightText, HighlightMatches } from "./HighlightText" +import { generateAvatarDataUrl } from "@/lib/avatar" + +const typeColors: Record = { + idea: "bg-purple-500", + task: "bg-blue-500", + bug: "bg-red-500", + research: "bg-green-500", + plan: "bg-amber-500", +} + +const typeLabels: Record = { + idea: "💡 Idea", + task: "📋 Task", + bug: "🐛 Bug", + research: "🔬 Research", + plan: "📐 Plan", +} + +const priorityColors: Record = { + low: "text-slate-400", + medium: "text-blue-400", + high: "text-orange-400", + urgent: "text-red-400", +} + +const formatStatusLabel = (status: TaskStatus) => + status === "todo" + ? "To Do" + : status + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" ") + +interface TaskSearchItemProps { + task: Task + query: string + sprintName?: string | null + onClick?: () => void +} + +function AvatarCircle({ + name, + avatarUrl, + seed, + sizeClass = "h-5 w-5", +}: { + name?: string + avatarUrl?: string + seed?: string + sizeClass?: string +}) { + const displayUrl = avatarUrl || generateAvatarDataUrl(seed || name || "default", name || "User") + return ( + {name + ) +} + +export function TaskSearchItem({ task, query, sprintName, onClick }: TaskSearchItemProps) { + const router = useRouter() + const attachmentCount = task.attachments?.length || 0 + + const handleClick = () => { + if (onClick) { + onClick() + } else { + router.push(`/tasks/${encodeURIComponent(task.id)}`) + } + } + + // Build matches for highlighting + const matchFields = [ + { field: "Description", value: task.description || "" }, + { field: "Tags", value: task.tags?.join(", ") || "" }, + ] + + const hasMatchInField = (fieldValue: string) => { + if (!query.trim()) return false + return fieldValue.toLowerCase().includes(query.toLowerCase()) + } + + return ( + + +
+ {/* Type Badge */} +
+ + {typeLabels[task.type]} + +
+ + {/* Main Content */} +
+ {/* Title Row */} +
+

+ +

+ + {task.priority} + +
+ + {/* Highlighted Match Display */} + + + {/* Individual Tag Highlights (if tags match) */} + {task.tags && task.tags.length > 0 && hasMatchInField(task.tags.join(" ")) && ( +
+ {task.tags.map((tag) => { + const isMatch = hasMatchInField(tag) + return ( + + + {isMatch ? : tag} + + ) + })} +
+ )} + + {/* Meta Row */} +
+ {/* Status */} + + {formatStatusLabel(task.status)} + + + {/* Sprint */} + {sprintName && ( + + + {sprintName} + + )} + + {/* Comments */} + {task.comments && task.comments.length > 0 && ( + + + {task.comments.length} + + )} + + {/* Attachments */} + {attachmentCount > 0 && ( + + + {attachmentCount} + + )} + + {/* Due Date */} + {task.dueDate && ( + + + {new Date(task.dueDate).toLocaleDateString()} + + )} + + {/* Assignee */} +
+ + + {task.assigneeName ? ( + hasMatchInField(task.assigneeName) ? ( + + ) : ( + task.assigneeName + ) + ) : ( + "Unassigned" + )} + +
+
+
+
+
+
+ ) +} + +export default TaskSearchItem