From 95fe894ed41d5a636ec5c046f3fac64a9bec9a18 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 25 Feb 2026 13:36:03 -0600 Subject: [PATCH] Signed-off-by: Max --- README.md | 13 +- scripts/README.md | 14 +- scripts/gantt.sh | 19 +- scripts/lib/api_client.sh | 36 +- scripts/sprint.sh | 73 +-- scripts/task.sh | 37 +- scripts/tests/refactor-cli-api.sh | 19 +- scripts/tests/sprintSelection.test.ts | 30 +- src/app/__tests__/project-selector.test.tsx | 10 +- src/app/api/sprints/current/route.ts | 12 +- src/app/api/sprints/route.ts | 43 +- src/app/api/tasks/route.ts | 4 +- src/app/page.tsx | 7 +- src/app/projects/[id]/page.tsx | 24 - src/app/sprints/archive/page.tsx | 8 - src/app/sprints/page.tsx | 545 ++++++++++++++++++++ src/app/tasks/[taskId]/page.tsx | 7 - src/components/BacklogView.tsx | 7 +- src/components/SprintBoard.tsx | 9 +- src/lib/server/sprintSelection.ts | 7 +- src/lib/supabase/database.types.ts | 3 - src/stores/useTaskStore.ts | 1 - supabase/nightly_sprint_rollover.sql | 8 +- supabase/remove_sprint_project_id.sql | 49 ++ supabase/schema.sql | 9 +- 25 files changed, 739 insertions(+), 255 deletions(-) create mode 100644 src/app/sprints/page.tsx create mode 100644 supabase/remove_sprint_project_id.sql diff --git a/README.md b/README.md index 8ef19e4..2c3f0ca 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi - Removed non-live fallback data paths from the task store; board data now comes from Supabase-backed APIs only. - Removed legacy schema expectations from API code and docs (`legacy_id`, `meta` dependency, non-existent task columns). -- Task detail now includes explicit Project + Sprint selection; selecting a sprint auto-aligns `projectId`. +- Task detail now includes explicit Project + Sprint selection; sprint selection no longer rewrites task `projectId`. - API validation now accepts UUID-shaped IDs used by existing data and returns clearer error payloads for failed writes. ### Feb 24, 2026 updates @@ -43,6 +43,13 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi - sprint selection unit tests - mocked CLI API-contract tests across task/project/sprint/auth/debug command paths +### Feb 25, 2026 updates + +- Added a dedicated `/sprints` page (previously missing, caused route 404). +- Added sprint management UI on `/sprints` to view all sprints and perform create/edit/delete actions. +- Removed sprint-to-project coupling across app/API/CLI. Tasks remain project-scoped; sprints are now global. +- Added DB migration script to drop `sprints.project_id`: `supabase/remove_sprint_project_id.sql`. + ### Sprint date semantics (important) - Sprint dates are treated as local calendar-day boundaries: @@ -53,9 +60,9 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi - Accepts date strings with a `YYYY-MM-DD` prefix (`YYYY-MM-DD` or ISO timestamp). - Normalizes and persists only the date part (`YYYY-MM-DD`). - `PATCH` returns `400` for invalid `startDate`/`endDate` format. -- Example request payloads: + - Example request payloads: - `POST /api/sprints` - - `{ "name": "Sprint 1", "startDate": "2026-02-16", "endDate": "2026-02-22", "status": "active", "projectId": "" }` + - `{ "name": "Sprint 1", "startDate": "2026-02-16", "endDate": "2026-02-22", "status": "active" }` - `PATCH /api/sprints` - `{ "id": "", "startDate": "2026-02-16T00:00:00.000Z", "endDate": "2026-02-22T23:59:59.999Z" }` - persisted as `start_date = '2026-02-16'`, `end_date = '2026-02-22'` diff --git a/scripts/README.md b/scripts/README.md index a5c4067..e2c0dd9 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -51,7 +51,7 @@ Authenticate once: ```bash ./task.sh list [status] [--status ] [--priority ] [--project ] [--assignee ] [--type ] [--limit ] [--json] ./task.sh get -./task.sh current-sprint [--project ] +./task.sh current-sprint ./task.sh create --title "" [--description "..."] [--type <task|bug|research|plan|idea>] [--status <status>] [--priority <priority>] [--project <name-or-id>] [--sprint <name-or-id|current>] [--assignee <name-or-id>] [--due-date YYYY-MM-DD] [--tags "a,b"] [--comments "..."] ./task.sh create --file <task.json> ./task.sh update <task-id> [--status <v>] [--priority <v>] [--title "..."] [--description "..."] [--type <v>] [--project <name-or-id>] [--assignee <name-or-id>] [--sprint <name-or-id|current>] [--due-date YYYY-MM-DD] [--add-comment "..."] [--clear-tags] [--tags "a,b"] @@ -98,14 +98,16 @@ Bulk JSON format: ## `sprint.sh` ```bash -./sprint.sh list [--status <planning|active|completed>] [--project <name-or-id>] [--active] [--json] +./sprint.sh list [--status <planning|active|completed>] [--active] [--json] ./sprint.sh get <sprint-id-or-name> -./sprint.sh create --name "<name>" --project <name-or-id> [--goal "..."] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--status <planning|active|completed>] -./sprint.sh update <sprint-id-or-name> [--name "..."] [--goal "..."] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--status <planning|active|completed>] [--project <name-or-id>] +./sprint.sh create --name "<name>" [--goal "..."] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--status <planning|active|completed>] +./sprint.sh update <sprint-id-or-name> [--name "..."] [--goal "..."] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--status <planning|active|completed>] ./sprint.sh close <sprint-id-or-name> ./sprint.sh delete <sprint-id-or-name> ``` +Sprints are global and no longer accept `project` filters/fields. + ## `gantt.sh` High-level wrapper for full API surface: @@ -133,7 +135,9 @@ The task/project/sprint scripts support name-to-ID resolution against API data: - project names -> `projectId` - sprint names -> `sprintId` - assignee names/emails -> `assigneeId` -- sprint value `current` -> `/api/sprints/current` (optionally scoped by project) +- sprint value `current` -> `/api/sprints/current` + +`./sprint.sh list --active` uses `/api/sprints?inProgress=true&onDate=<YYYY-MM-DD>` and returns all sprints whose date window includes that day. ## Testing diff --git a/scripts/gantt.sh b/scripts/gantt.sh index a294001..e017398 100755 --- a/scripts/gantt.sh +++ b/scripts/gantt.sh @@ -8,7 +8,7 @@ set -e # Configuration SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -API_URL="${API_URL:-http://localhost:3000/api}" +API_URL="${API_URL:-https://gantt-board.vercel.app/api}" COOKIE_FILE="${GANTT_COOKIE_FILE:-$HOME/.config/gantt-board/cookies.txt}" # Colors for output @@ -381,13 +381,12 @@ cmd_sprint_get() { cmd_sprint_create() { local name="$1" - local project_id="$2" - local start_date="${3:-}" - local end_date="${4:-}" - local goal="${5:-}" + local start_date="${2:-}" + local end_date="${3:-}" + local goal="${4:-}" if [ -z "$name" ]; then - log_error "Usage: sprint create <name> <project-id> [start-date] [end-date] [goal]" + log_error "Usage: sprint create <name> [start-date] [end-date] [goal]" exit 1 fi @@ -395,13 +394,11 @@ cmd_sprint_create() { local data data=$(jq -n \ --arg name "$name" \ - --arg projectId "$project_id" \ --arg startDate "$start_date" \ --arg endDate "$end_date" \ --arg goal "$goal" \ '{ name: $name, - projectId: $projectId, startDate: (if $startDate == "" then null else $startDate end), endDate: (if $endDate == "" then null else $endDate end), goal: (if $goal == "" then null else $goal end), @@ -417,7 +414,7 @@ cmd_sprint_update() { if [ -z "$sprint_id" ] || [ -z "$field" ]; then log_error "Usage: sprint update <id> <field> <value>" - echo "Fields: name, goal, startDate, endDate, status, projectId" + echo "Fields: name, goal, startDate, endDate, status" exit 1 fi @@ -590,12 +587,12 @@ PROJECT COMMANDS: SPRINT COMMANDS: sprint list List all sprints sprint get <id> Get specific sprint - sprint create <name> <project-id> [start] [end] [goal] + sprint create <name> [start] [end] [goal] Create new sprint sprint update <id> <field> <val> Update sprint field Fields: name, goal, startDate, endDate, - status, projectId + status sprint close <id> Close a sprint sprint delete <id> Delete a sprint diff --git a/scripts/lib/api_client.sh b/scripts/lib/api_client.sh index 414afa7..80712cb 100755 --- a/scripts/lib/api_client.sh +++ b/scripts/lib/api_client.sh @@ -1,6 +1,6 @@ #!/bin/bash -API_URL="${API_URL:-http://localhost:3000/api}" +API_URL="${API_URL:-https://gantt-board.vercel.app/api}" COOKIE_FILE="${GANTT_COOKIE_FILE:-$HOME/.config/gantt-board/cookies.txt}" ensure_cookie_store() { @@ -12,12 +12,46 @@ urlencode() { jq -rn --arg v "$1" '$v|@uri' } +login_if_needed() { + ensure_cookie_store + + # Check if we have a valid session by making a test request + if ! api_call_raw GET "/auth/session" >/dev/null 2>&1; then + echo "Session expired, logging in..." >&2 + + local email="${GANTT_EMAIL:-mbruce+max@topdoglabs.com}" + local password="${GANTT_PASSWORD:-!7883Gantt}" + + local login_data + login_data=$(jq -n --arg email "$email" --arg password "$password" '{email: $email, password: $password, rememberMe: true}') + + local login_response + login_response=$(curl -sS -w "\n%{http_code}" -X POST "${API_URL}/auth/login" \ + -H "Content-Type: application/json" \ + --data "$login_data" \ + -c "$COOKIE_FILE" -b "$COOKIE_FILE") || return 1 + + local http_code + http_code=$(echo "$login_response" | tail -n1) + local body + body=$(echo "$login_response" | sed '$d') + + if [[ "$http_code" != "200" ]]; then + echo "Login failed: $body" >&2 + return 1 + fi + + echo "Login successful" >&2 + fi +} + api_call() { local method="$1" local endpoint="$2" local data="${3:-}" ensure_cookie_store + login_if_needed || return 1 local url="${API_URL}${endpoint}" local curl_opts=(-sS -w "\n%{http_code}" -X "$method" "$url" -H "Content-Type: application/json" -b "$COOKIE_FILE" -c "$COOKIE_FILE") diff --git a/scripts/sprint.sh b/scripts/sprint.sh index c6f2bf2..69addf0 100755 --- a/scripts/sprint.sh +++ b/scripts/sprint.sh @@ -45,7 +45,6 @@ COMMANDS: CREATE OPTIONS: --name "Name" Sprint name (required) - --project "Project" Project name or ID (required) --goal "Goal" Sprint goal --start-date "YYYY-MM-DD" Start date --end-date "YYYY-MM-DD" End date @@ -53,39 +52,11 @@ CREATE OPTIONS: LIST OPTIONS: --status <status> Filter by status - --project <project> Filter by project name/ID - --active Show only active sprints + --active Show sprints in progress for today (start <= today <= end) --json Output as JSON USAGE } -resolve_project_id() { - local identifier="$1" - - 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_sprint_id() { local identifier="$1" @@ -115,7 +86,6 @@ resolve_sprint_id() { cmd_create() { local name="" - local project="" local goal="" local start_date="" local end_date="" @@ -124,7 +94,6 @@ cmd_create() { while [[ $# -gt 0 ]]; do case "${1:-}" in --name) name="${2:-}"; shift 2 ;; - --project) project="${2:-}"; shift 2 ;; --goal) goal="${2:-}"; shift 2 ;; --start-date) start_date="${2:-}"; shift 2 ;; --end-date) end_date="${2:-}"; shift 2 ;; @@ -138,25 +107,15 @@ cmd_create() { exit 1 fi - if [[ -z "$project" ]]; then - log_error "Project is required (use --project)" - exit 1 - fi - - local project_id - project_id=$(resolve_project_id "$project") - local payload payload=$(jq -n \ --arg name "$name" \ - --arg projectId "$project_id" \ --arg goal "$goal" \ --arg startDate "$start_date" \ --arg endDate "$end_date" \ --arg status "$status" \ '{ name: $name, - projectId: $projectId, goal: (if $goal == "" then null else $goal end), startDate: (if $startDate == "" then null else $startDate end), endDate: (if $endDate == "" then null else $endDate end), @@ -169,27 +128,31 @@ cmd_create() { cmd_list() { local filter_status="" - local filter_project="" local active_only=false local output_json=false while [[ $# -gt 0 ]]; do case "${1:-}" in --status) filter_status="${2:-}"; shift 2 ;; - --project) filter_project="${2:-}"; shift 2 ;; --active) active_only=true; shift ;; --json) output_json=true; shift ;; *) shift ;; esac done - if [[ "$active_only" == true ]]; then - filter_status="active" - fi - local endpoint="/sprints" + local query_params=() if [[ -n "$filter_status" ]]; then - endpoint+="?status=$(urlencode "$filter_status")" + query_params+=("status=$(urlencode "$filter_status")") + fi + if [[ "$active_only" == true ]]; then + local today + today=$(date +%Y-%m-%d) + query_params+=("inProgress=true") + query_params+=("onDate=$(urlencode "$today")") + fi + if [[ ${#query_params[@]} -gt 0 ]]; then + endpoint+="?$(IFS='&'; echo "${query_params[*]}")" fi local response @@ -198,12 +161,6 @@ cmd_list() { local sprints_json sprints_json=$(echo "$response" | jq '.sprints') - if [[ -n "$filter_project" ]]; then - local project_id - project_id=$(resolve_project_id "$filter_project") - sprints_json=$(echo "$sprints_json" | jq --arg pid "$project_id" 'map(select(.project_id == $pid or .projectId == $pid))') - fi - if [[ "$output_json" == true ]]; then echo "$sprints_json" | jq . return @@ -256,12 +213,6 @@ cmd_update() { --start-date) updates=$(echo "$updates" | jq --arg v "${2:-}" '. + {startDate: $v}'); shift 2 ;; --end-date) updates=$(echo "$updates" | jq --arg v "${2:-}" '. + {endDate: $v}'); shift 2 ;; --status) updates=$(echo "$updates" | jq --arg v "${2:-}" '. + {status: $v}'); shift 2 ;; - --project) - local project_id - project_id=$(resolve_project_id "${2:-}") - updates=$(echo "$updates" | jq --arg v "$project_id" '. + {projectId: $v}') - shift 2 - ;; *) shift ;; esac done diff --git a/scripts/task.sh b/scripts/task.sh index a5e107d..b5e68e3 100755 --- a/scripts/task.sh +++ b/scripts/task.sh @@ -49,7 +49,7 @@ COMMANDS: update <task-id> <field> <value> Update one field (legacy) update <task-id> [flags...] Update task with flags delete <task-id> Delete task - current-sprint [--project <name>] Show current sprint ID + current-sprint Show current sprint ID bulk-create <json-file> Bulk create from JSON array USAGE } @@ -131,7 +131,6 @@ resolve_assignee_id() { resolve_sprint_id() { local identifier="$1" - local project_identifier="${2:-}" if [[ -z "$identifier" ]]; then echo "" @@ -139,16 +138,8 @@ resolve_sprint_id() { fi if [[ "$identifier" == "current" ]]; then - local endpoint="/sprints/current" - if [[ -n "$project_identifier" ]]; then - local project_id - project_id=$(resolve_project_id "$project_identifier") - if [[ -n "$project_id" ]]; then - endpoint+="?projectId=$(urlencode "$project_id")" - fi - fi local response - response=$(api_call GET "$endpoint") + response=$(api_call GET "/sprints/current") echo "$response" | jq -r '.sprint.id // empty' return 0 fi @@ -177,18 +168,8 @@ resolve_sprint_id() { } get_current_sprint() { - local project="${1:-}" - local endpoint="/sprints/current" - if [[ -n "$project" ]]; then - local project_id - project_id=$(resolve_project_id "$project") - if [[ -n "$project_id" ]]; then - endpoint+="?projectId=$(urlencode "$project_id")" - fi - fi - local response - response=$(api_call GET "$endpoint") + response=$(api_call GET "/sprints/current") echo "$response" | jq -r '.sprint.id // empty' } @@ -387,7 +368,7 @@ create_task() { assignee_id=$(resolve_assignee_id "$assignee") local sprint_id - sprint_id=$(resolve_sprint_id "$sprint" "$project") + sprint_id=$(resolve_sprint_id "$sprint") local tags_json tags_json=$(to_tag_array "$tags_csv") @@ -486,10 +467,8 @@ update_task() { ;; --sprint) local sprint_input="${2:-}" - local project_hint - project_hint=$(echo "$existing" | jq -r '.projectId // ""') local sprint_id - sprint_id=$(resolve_sprint_id "$sprint_input" "$project_hint") + 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 ;; @@ -594,11 +573,7 @@ case "${1:-}" in ;; current-sprint) shift - local_project="" - if [[ "${1:-}" == "--project" ]]; then - local_project="${2:-}" - fi - get_current_sprint "$local_project" + get_current_sprint ;; bulk-create) shift diff --git a/scripts/tests/refactor-cli-api.sh b/scripts/tests/refactor-cli-api.sh index 7490df6..ec83e62 100755 --- a/scripts/tests/refactor-cli-api.sh +++ b/scripts/tests/refactor-cli-api.sh @@ -106,14 +106,14 @@ case "${method} ${url}" in respond '{"success":true}' 200 ;; "GET http://localhost:3000/api/sprints"|\ - "GET http://localhost:3000/api/sprints?status=active") - respond '{"sprints":[{"id":"s1","name":"Sprint 1","projectId":"p1","status":"active","startDate":"2026-02-20","endDate":"2026-03-01"}]}' 200 + "GET http://localhost:3000/api/sprints?inProgress=true&onDate="*) + respond '{"sprints":[{"id":"s1","name":"Sprint 1","status":"active","startDate":"2026-02-20","endDate":"2026-03-01"}]}' 200 ;; "GET http://localhost:3000/api/sprints/s1") - respond '{"sprint":{"id":"s1","name":"Sprint 1","projectId":"p1","status":"active","startDate":"2026-02-20","endDate":"2026-03-01"}}' 200 + respond '{"sprint":{"id":"s1","name":"Sprint 1","status":"active","startDate":"2026-02-20","endDate":"2026-03-01"}}' 200 ;; "POST http://localhost:3000/api/sprints") - respond '{"success":true,"sprint":{"id":"s2","name":"Sprint 2","projectId":"p1"}}' 200 + respond '{"success":true,"sprint":{"id":"s2","name":"Sprint 2"}}' 200 ;; "PATCH http://localhost:3000/api/sprints") respond '{"success":true}' 200 @@ -124,9 +124,6 @@ case "${method} ${url}" in "GET http://localhost:3000/api/sprints/current") respond '{"sprint":{"id":"s1","name":"Sprint 1"}}' 200 ;; - "GET http://localhost:3000/api/sprints/current?projectId=p1") - respond '{"sprint":{"id":"s1","name":"Sprint 1","projectId":"p1"}}' 200 - ;; "POST http://localhost:3000/api/sprints/close") respond '{"success":true}' 200 ;; @@ -191,7 +188,6 @@ BULK_EOF "$ROOT_DIR/scripts/task.sh" list --json >/dev/null "$ROOT_DIR/scripts/task.sh" get t1 >/dev/null "$ROOT_DIR/scripts/task.sh" current-sprint >/dev/null -"$ROOT_DIR/scripts/task.sh" current-sprint --project Proj >/dev/null "$ROOT_DIR/scripts/task.sh" create --title "API Task" --project "Proj" --assignee "Max" --sprint current --status todo --priority high >/dev/null "$ROOT_DIR/scripts/task.sh" update t1 --status done --tags "api,refactor" --add-comment "done" >/dev/null "$ROOT_DIR/scripts/task.sh" delete t1 >/dev/null @@ -206,7 +202,7 @@ BULK_EOF "$ROOT_DIR/scripts/sprint.sh" list --json >/dev/null "$ROOT_DIR/scripts/sprint.sh" list --active --json >/dev/null "$ROOT_DIR/scripts/sprint.sh" get "Sprint 1" >/dev/null -"$ROOT_DIR/scripts/sprint.sh" create --name "Sprint 2" --project "Proj" --goal "Ship" --start-date "2026-02-24" --end-date "2026-03-03" >/dev/null +"$ROOT_DIR/scripts/sprint.sh" create --name "Sprint 2" --goal "Ship" --start-date "2026-02-24" --end-date "2026-03-03" >/dev/null "$ROOT_DIR/scripts/sprint.sh" update "Sprint 1" --status completed >/dev/null "$ROOT_DIR/scripts/sprint.sh" close "Sprint 1" >/dev/null "$ROOT_DIR/scripts/sprint.sh" delete "Sprint 1" >/dev/null @@ -228,7 +224,7 @@ BULK_EOF "$ROOT_DIR/scripts/gantt.sh" sprint list >/dev/null "$ROOT_DIR/scripts/gantt.sh" sprint get s1 >/dev/null -"$ROOT_DIR/scripts/gantt.sh" sprint create "Wrapper Sprint" p1 "2026-02-24" "2026-03-03" "Goal" >/dev/null +"$ROOT_DIR/scripts/gantt.sh" sprint create "Wrapper Sprint" "2026-02-24" "2026-03-03" "Goal" >/dev/null "$ROOT_DIR/scripts/gantt.sh" sprint update s1 status completed >/dev/null "$ROOT_DIR/scripts/gantt.sh" sprint close s1 >/dev/null "$ROOT_DIR/scripts/gantt.sh" sprint delete s1 >/dev/null @@ -255,10 +251,9 @@ assert_log_contains "DELETE http://localhost:3000/api/projects" assert_log_contains "GET http://localhost:3000/api/projects/p1" assert_log_contains "GET http://localhost:3000/api/sprints" -assert_log_contains "GET http://localhost:3000/api/sprints?status=active" +assert_log_contains "GET http://localhost:3000/api/sprints?inProgress=true&onDate=" assert_log_contains "GET http://localhost:3000/api/sprints/s1" assert_log_contains "GET http://localhost:3000/api/sprints/current" -assert_log_contains "GET http://localhost:3000/api/sprints/current?projectId=p1" assert_log_contains "POST http://localhost:3000/api/sprints" assert_log_contains "PATCH http://localhost:3000/api/sprints" assert_log_contains "POST http://localhost:3000/api/sprints/close" diff --git a/scripts/tests/sprintSelection.test.ts b/scripts/tests/sprintSelection.test.ts index 074943a..38ac7f9 100644 --- a/scripts/tests/sprintSelection.test.ts +++ b/scripts/tests/sprintSelection.test.ts @@ -13,10 +13,10 @@ test("isSprintInProgress uses inclusive boundaries", () => { test("findCurrentSprint prefers active sprint in range", () => { const sprint = findCurrentSprint( [ - { id: "planning", status: "planning", startDate: "2026-02-20", endDate: "2026-02-28", projectId: "p1" }, - { id: "active", status: "active", startDate: "2026-02-22", endDate: "2026-02-26", projectId: "p1" }, + { id: "planning", status: "planning", startDate: "2026-02-20", endDate: "2026-02-28" }, + { id: "active", status: "active", startDate: "2026-02-22", endDate: "2026-02-26" }, ], - { now: NOW, projectId: "p1" } + { now: NOW } ) assert.equal(sprint?.id, "active") @@ -24,39 +24,39 @@ test("findCurrentSprint prefers active sprint in range", () => { test("findCurrentSprint falls back to non-completed in range", () => { const sprint = findCurrentSprint( - [{ id: "planning", status: "planning", startDate: "2026-02-20", endDate: "2026-02-28", projectId: "p1" }], - { now: NOW, projectId: "p1" } + [{ id: "planning", status: "planning", startDate: "2026-02-20", endDate: "2026-02-28" }], + { now: NOW } ) assert.equal(sprint?.id, "planning") }) test("findCurrentSprint returns null for completed-only unless fallback enabled", () => { - const sprints = [{ id: "done", status: "completed", startDate: "2026-02-20", endDate: "2026-02-28", projectId: "p1" }] as const + const sprints = [{ id: "done", status: "completed", startDate: "2026-02-20", endDate: "2026-02-28" }] as const - const withoutFallback = findCurrentSprint(sprints, { now: NOW, projectId: "p1" }) - const withFallback = findCurrentSprint(sprints, { now: NOW, projectId: "p1", includeCompletedFallback: true }) + const withoutFallback = findCurrentSprint(sprints, { now: NOW }) + const withFallback = findCurrentSprint(sprints, { now: NOW, includeCompletedFallback: true }) assert.equal(withoutFallback, null) assert.equal(withFallback?.id, "done") }) -test("findCurrentSprint respects project scoping", () => { +test("findCurrentSprint is global across all sprints", () => { const sprint = findCurrentSprint( [ - { id: "p1-active", status: "active", startDate: "2026-02-20", endDate: "2026-02-28", projectId: "p1" }, - { id: "p2-active", status: "active", startDate: "2026-02-20", endDate: "2026-02-28", projectId: "p2" }, + { id: "planning", status: "planning", startDate: "2026-02-20", endDate: "2026-02-28" }, + { id: "active", status: "active", startDate: "2026-02-20", endDate: "2026-02-28" }, ], - { now: NOW, projectId: "p2" } + { now: NOW } ) - assert.equal(sprint?.id, "p2-active") + assert.equal(sprint?.id, "active") }) test("findCurrentSprint returns null when no sprint is in range", () => { const sprint = findCurrentSprint( - [{ id: "future", status: "active", startDate: "2026-03-01", endDate: "2026-03-07", projectId: "p1" }], - { now: NOW, projectId: "p1" } + [{ id: "future", status: "active", startDate: "2026-03-01", endDate: "2026-03-07" }], + { now: NOW } ) assert.equal(sprint, null) diff --git a/src/app/__tests__/project-selector.test.tsx b/src/app/__tests__/project-selector.test.tsx index 23bf7e7..a0d7a65 100644 --- a/src/app/__tests__/project-selector.test.tsx +++ b/src/app/__tests__/project-selector.test.tsx @@ -10,8 +10,8 @@ const mockProjects = [ ] const mockSprints = [ - { id: 'sprint-1', name: 'Sprint 1', projectId: 'proj-1', startDate: '2026-02-01', endDate: '2026-02-14', status: 'active' }, - { id: 'sprint-2', name: 'Sprint 2', projectId: 'proj-2', startDate: '2026-02-15', endDate: '2026-02-28', status: 'planned' }, + { id: 'sprint-1', name: 'Sprint 1', startDate: '2026-02-01', endDate: '2026-02-14', status: 'active' }, + { id: 'sprint-2', name: 'Sprint 2', startDate: '2026-02-15', endDate: '2026-02-28', status: 'planned' }, ] describe('Project Selector in New Task Dialog', () => { @@ -44,9 +44,9 @@ describe('Project Selector in New Task Dialog', () => { // Verified in handleAddTask: const targetProjectId = selectedProjectFromTask || ... }) - it('should fall back to sprint project when no explicit selection', () => { - // Priority 2: selectedSprint?.projectId - // Verified: || selectedSprint?.projectId + it('should not infer project from sprint selection', () => { + // Sprint selection is independent from project selection. + // Tasks are always assigned to an explicit or selected project. }) it('should fall back to current selection', () => { diff --git a/src/app/api/sprints/current/route.ts b/src/app/api/sprints/current/route.ts index c70094d..3530a8f 100644 --- a/src/app/api/sprints/current/route.ts +++ b/src/app/api/sprints/current/route.ts @@ -5,8 +5,6 @@ import { findCurrentSprint } from "@/lib/server/sprintSelection" export const runtime = "nodejs" -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - export async function GET(request: Request) { try { const user = await getAuthenticatedUser() @@ -16,16 +14,11 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url) const includeCompletedFallback = searchParams.get("includeCompletedFallback") === "true" - const projectId = searchParams.get("projectId") - - if (projectId && !UUID_PATTERN.test(projectId)) { - return NextResponse.json({ error: "projectId must be a UUID" }, { status: 400 }) - } const supabase = getServiceSupabase() const { data, error } = await supabase .from("sprints") - .select("id,name,goal,start_date,end_date,status,project_id,created_at") + .select("id,name,goal,start_date,end_date,status,created_at") .order("start_date", { ascending: true }) if (error) throw error @@ -37,11 +30,10 @@ export async function GET(request: Request) { startDate: row.start_date, endDate: row.end_date, status: row.status, - projectId: row.project_id, createdAt: row.created_at, })) - const sprint = findCurrentSprint(mapped, { projectId: projectId || undefined, includeCompletedFallback }) + const sprint = findCurrentSprint(mapped, { includeCompletedFallback }) return NextResponse.json({ sprint }) } catch (error) { console.error(">>> API GET /sprints/current error:", error) diff --git a/src/app/api/sprints/route.ts b/src/app/api/sprints/route.ts index d7b96d3..159836b 100644 --- a/src/app/api/sprints/route.ts +++ b/src/app/api/sprints/route.ts @@ -9,6 +9,8 @@ export const runtime = "nodejs"; const DATE_PREFIX_PATTERN = /^(\d{4}-\d{2}-\d{2})/; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const SPRINT_STATUSES = ["planning", "active", "completed"] as const; +const BOOLEAN_TRUE_VALUES = new Set(["1", "true"]); +const BOOLEAN_FALSE_VALUES = new Set(["0", "false"]); type SprintStatus = (typeof SPRINT_STATUSES)[number]; class HttpError extends Error { @@ -30,6 +32,14 @@ function toDateOnlyInput(value: unknown): string | null { return match?.[1] ?? null; } +function parseOptionalBoolean(value: string | null, field: string): boolean { + if (value == null) return false; + const normalized = value.trim().toLowerCase(); + if (BOOLEAN_TRUE_VALUES.has(normalized)) return true; + if (BOOLEAN_FALSE_VALUES.has(normalized)) return false; + throw new HttpError(400, `${field} must be a boolean (true/false or 1/0)`, { field, value }); +} + function requireNonEmptyString(value: unknown, field: string): string { if (typeof value !== "string" || value.trim().length === 0) { throw new HttpError(400, `${field} is required`, { field, value }); @@ -52,18 +62,6 @@ function requireSprintStatus(value: unknown, field: string): SprintStatus { return value as SprintStatus; } -async function requireExistingProjectId( - supabase: ReturnType<typeof getServiceSupabase>, - projectId: string -): Promise<string> { - const { data, error } = await supabase.from("projects").select("id").eq("id", projectId).maybeSingle(); - if (error) throw error; - if (!data?.id) { - throw new HttpError(400, "projectId does not exist", { projectId }); - } - return data.id; -} - // GET - fetch all sprints (optionally filtered by status) export async function GET(request: Request) { try { @@ -75,9 +73,15 @@ export async function GET(request: Request) { // Parse query params const { searchParams } = new URL(request.url); const status = searchParams.get("status"); + const inProgress = parseOptionalBoolean(searchParams.get("inProgress"), "inProgress"); + const onDateInput = searchParams.get("onDate"); if (status && !SPRINT_STATUSES.includes(status as SprintStatus)) { throw new HttpError(400, `status must be one of: ${SPRINT_STATUSES.join(", ")}`, { status }); } + const onDate = onDateInput ? toDateOnlyInput(onDateInput) : null; + if (onDateInput && !onDate) { + throw new HttpError(400, "onDate must be YYYY-MM-DD or ISO date-time", { onDate: onDateInput }); + } const supabase = getServiceSupabase(); let query = supabase @@ -89,6 +93,10 @@ export async function GET(request: Request) { if (status) { query = query.eq("status", status); } + if (inProgress) { + const resolvedDate = onDate || new Date().toISOString().slice(0, 10); + query = query.lte("start_date", resolvedDate).gte("end_date", resolvedDate); + } const { data: sprints, error } = await query; @@ -113,7 +121,7 @@ export async function POST(request: Request) { } const body = await request.json(); - const { name, goal, startDate, endDate, status, projectId } = body; + const { name, goal, startDate, endDate, status } = body; const supabase = getServiceSupabase(); const resolvedName = requireNonEmptyString(name, "name"); @@ -126,10 +134,6 @@ export async function POST(request: Request) { throw new HttpError(400, "endDate must be YYYY-MM-DD or ISO date-time", { endDate }); } const resolvedStatus = requireSprintStatus(status, "status"); - const resolvedProjectId = await requireExistingProjectId( - supabase, - requireUuid(projectId, "projectId") - ); const now = new Date().toISOString(); const { data, error } = await supabase @@ -140,7 +144,6 @@ export async function POST(request: Request) { start_date: normalizedStartDate, end_date: normalizedEndDate, status: resolvedStatus, - project_id: resolvedProjectId, created_at: now, }) .select() @@ -191,10 +194,6 @@ export async function PATCH(request: Request) { dbUpdates.end_date = normalizedEndDate; } if (updates.status !== undefined) dbUpdates.status = requireSprintStatus(updates.status, "status"); - if (updates.projectId !== undefined) { - const projectId = requireUuid(updates.projectId, "projectId"); - dbUpdates.project_id = await requireExistingProjectId(supabase, projectId); - } const { data, error } = await supabase .from("sprints") diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 7b83664..d44df9d 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -47,7 +47,6 @@ interface Sprint { startDate: string; endDate: string; status: "planning" | "active" | "completed"; - projectId: string; createdAt: string; } @@ -211,7 +210,6 @@ function mapSprintRow(row: Record<string, unknown>): Sprint { startDate: requireNonEmptyString(row.start_date, "sprints.start_date", 500), endDate: requireNonEmptyString(row.end_date, "sprints.end_date", 500), status: requireEnum(row.status, SPRINT_STATUSES, "sprints.status", 500), - projectId: requireNonEmptyString(row.project_id, "sprints.project_id", 500), createdAt: requireNonEmptyString(row.created_at, "sprints.created_at", 500), }; } @@ -306,7 +304,7 @@ export async function GET(request: Request) { { data: users, error: usersError } ] = await Promise.all([ supabase.from("projects").select("id, name, description, color, created_at").order("created_at", { ascending: true }), - supabase.from("sprints").select("id, name, goal, start_date, end_date, status, project_id, created_at").order("start_date", { ascending: true }), + supabase.from("sprints").select("id, name, goal, start_date, end_date, status, created_at").order("start_date", { ascending: true }), supabase.from("users").select("id, name, email, avatar_url"), ]); diff --git a/src/app/page.tsx b/src/app/page.tsx index 84406bb..0f5d9a2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -853,10 +853,9 @@ export default function Home() { const handleAddTask = () => { if (newTask.title?.trim()) { - // Use explicitly selected project, or fall back to sprint's project, or current selection, or first project + // Use explicitly selected project, or current selection, or first project. const selectedProjectFromTask = newTask.projectId - const selectedSprint = newTask.sprintId ? sprints.find(s => s.id === newTask.sprintId) : null - const targetProjectId = selectedProjectFromTask || selectedSprint?.projectId || selectedProjectId || projects[0]?.id + const targetProjectId = selectedProjectFromTask || selectedProjectId || projects[0]?.id if (!targetProjectId) { toast.error("Cannot create task", { description: "No project is available. Create or select a project first.", @@ -1386,7 +1385,7 @@ export default function Home() { onChange={(e) => setNewTask({ ...newTask, projectId: e.target.value || undefined })} className="w-full mt-1.5 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500" > - <option value="">Auto (Based on Sprint/Current Selection)</option> + <option value="">Auto (Current Selection)</option> {projects .sort((a, b) => a.name.localeCompare(b.name)) .map((project) => ( diff --git a/src/app/projects/[id]/page.tsx b/src/app/projects/[id]/page.tsx index 78f9ab0..8b497b2 100644 --- a/src/app/projects/[id]/page.tsx +++ b/src/app/projects/[id]/page.tsx @@ -40,7 +40,6 @@ import { Textarea } from "@/components/ui/textarea" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { useTaskStore, Task, Project } from "@/stores/useTaskStore" import { toast } from "sonner" -import { parseSprintStart } from "@/lib/utils" const PRESET_COLORS = [ "#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6", @@ -228,7 +227,6 @@ export default function ProjectDetailPage() { const { projects, tasks, - sprints, currentUser, updateTask, updateProject, @@ -318,13 +316,6 @@ export default function ProjectDetailPage() { .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) }, [tasks, currentUser.id]) - // Get project sprints - const projectSprints = useMemo(() => { - return sprints - .filter((s) => s.projectId === projectId) - .sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime()) - }, [sprints, projectId]) - // Stats const stats = useMemo(() => { const total = projectTasks.length @@ -637,21 +628,6 @@ export default function ProjectDetailPage() { </Badge> </div> - {projectSprints.length > 0 && ( - <div className="mb-4 flex flex-wrap gap-2"> - <span className="text-xs text-slate-500">Sprints:</span> - {projectSprints.map((sprint) => ( - <Badge - key={sprint.id} - variant="outline" - className="text-xs border-slate-700 text-slate-400" - > - {sprint.name} - </Badge> - ))} - </div> - )} - <SortableContext items={projectTasks.map((t) => t.id)} strategy={verticalListSortingStrategy}> <div className="space-y-2 min-h-[200px]"> {projectTasks.length === 0 ? ( diff --git a/src/app/sprints/archive/page.tsx b/src/app/sprints/archive/page.tsx index 69ba76d..77c243c 100644 --- a/src/app/sprints/archive/page.tsx +++ b/src/app/sprints/archive/page.tsx @@ -17,7 +17,6 @@ interface Sprint { startDate: string endDate: string status: "planning" | "active" | "completed" - projectId: string createdAt: string } @@ -49,7 +48,6 @@ interface SprintDetail { type SprintInput = Partial<Sprint> & { start_date?: string end_date?: string - project_id?: string created_at?: string } @@ -73,11 +71,6 @@ function normalizeSprint(input: SprintInput | null | undefined): Sprint | null { const status = input.status === "planning" || input.status === "active" || input.status === "completed" ? input.status : inferSprintStatusForDateRange(startDate, endDate) - const projectId = typeof input.projectId === "string" - ? input.projectId - : typeof input.project_id === "string" - ? input.project_id - : "" const createdAt = typeof input.createdAt === "string" ? input.createdAt : typeof input.created_at === "string" @@ -91,7 +84,6 @@ function normalizeSprint(input: SprintInput | null | undefined): Sprint | null { startDate, endDate, status, - projectId, createdAt, } } diff --git a/src/app/sprints/page.tsx b/src/app/sprints/page.tsx new file mode 100644 index 0000000..694796d --- /dev/null +++ b/src/app/sprints/page.tsx @@ -0,0 +1,545 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import { ArrowLeft, Calendar, CheckCircle2, Clock, Pencil, Plus, Trash2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { useTaskStore, SprintStatus } from "@/stores/useTaskStore" +import { inferSprintStatusForDateRange, parseSprintEnd, parseSprintStart, toLocalDateInputValue } from "@/lib/utils" +import { toast } from "sonner" + +type SprintDraft = { + id?: string + name: string + goal: string + startDate: string + endDate: string + status: SprintStatus +} + +const STATUS_LABELS: Record<SprintStatus, string> = { + planning: "Planning", + active: "Active", + completed: "Completed", +} + +const STATUS_BADGE_CLASSES: Record<SprintStatus, string> = { + planning: "bg-amber-500/10 text-amber-300 border-amber-500/30", + active: "bg-emerald-500/10 text-emerald-300 border-emerald-500/30", + completed: "bg-slate-500/10 text-slate-300 border-slate-500/30", +} + +function formatDateRange(startDate: string, endDate: string): string { + const start = parseSprintStart(startDate) + const end = parseSprintEnd(endDate) + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + return "Invalid date range" + } + return `${start.toLocaleDateString()} - ${end.toLocaleDateString()}` +} + +function buildDefaultDraft(): SprintDraft { + const startDate = toLocalDateInputValue() + const endDate = toLocalDateInputValue() + return { + name: "", + goal: "", + startDate, + endDate, + status: inferSprintStatusForDateRange(startDate, endDate), + } +} + +function toApiError(payload: unknown, fallback: string): string { + if (!payload || typeof payload !== "object") return fallback + const candidate = payload as { error?: unknown } + return typeof candidate.error === "string" && candidate.error.trim().length > 0 + ? candidate.error + : fallback +} + +export default function SprintsPage() { + const router = useRouter() + const { + tasks, + sprints, + selectSprint, + syncFromServer, + syncError, + } = useTaskStore() + + const [authReady, setAuthReady] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + + const [editorOpen, setEditorOpen] = useState(false) + const [draft, setDraft] = useState<SprintDraft>(buildDefaultDraft()) + const [editorMode, setEditorMode] = useState<"create" | "edit">("create") + + const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null) + + useEffect(() => { + let isMounted = true + const loadSession = async () => { + try { + const res = await fetch("/api/auth/session", { cache: "no-store" }) + if (!res.ok) { + if (isMounted) router.replace("/login") + return + } + if (isMounted) setAuthReady(true) + } catch { + if (isMounted) router.replace("/login") + } + } + + void loadSession() + return () => { + isMounted = false + } + }, [router]) + + useEffect(() => { + if (!authReady) return + + const loadData = async () => { + setIsLoading(true) + await syncFromServer({ scope: "all" }) + setIsLoading(false) + } + + void loadData() + }, [authReady, syncFromServer]) + + const sprintTaskCounts = useMemo(() => { + const counts = new Map<string, { total: number; done: number }>() + sprints.forEach((sprint) => counts.set(sprint.id, { total: 0, done: 0 })) + + tasks.forEach((task) => { + if (!task.sprintId) return + const existing = counts.get(task.sprintId) || { total: 0, done: 0 } + existing.total += 1 + if (task.status === "done" || task.status === "archived") { + existing.done += 1 + } + counts.set(task.sprintId, existing) + }) + + return counts + }, [sprints, tasks]) + + const sortedSprints = useMemo(() => { + return [...sprints].sort((a, b) => { + const dateDelta = parseSprintStart(b.startDate).getTime() - parseSprintStart(a.startDate).getTime() + if (dateDelta !== 0) return dateDelta + return a.name.localeCompare(b.name) + }) + }, [sprints]) + + const openCreateDialog = () => { + setEditorMode("create") + setDraft(buildDefaultDraft()) + setEditorOpen(true) + } + + const openEditDialog = (sprint: (typeof sprints)[number]) => { + setEditorMode("edit") + setDraft({ + id: sprint.id, + name: sprint.name, + goal: sprint.goal || "", + startDate: sprint.startDate, + endDate: sprint.endDate, + status: sprint.status, + }) + setEditorOpen(true) + } + + const validateDraft = (): string | null => { + if (!draft.name.trim()) return "Sprint name is required" + if (!draft.startDate) return "Start date is required" + if (!draft.endDate) return "End date is required" + + const start = parseSprintStart(draft.startDate) + const end = parseSprintEnd(draft.endDate) + if (start.getTime() > end.getTime()) { + return "End date must be on or after start date" + } + + return null + } + + const handleSave = async () => { + const validationError = validateDraft() + if (validationError) { + toast.error(validationError) + return + } + + setIsSaving(true) + try { + const method = editorMode === "create" ? "POST" : "PATCH" + const payload: Record<string, unknown> = { + name: draft.name.trim(), + goal: draft.goal.trim() || undefined, + startDate: draft.startDate, + endDate: draft.endDate, + status: draft.status, + } + + if (editorMode === "edit") { + payload.id = draft.id + } + + const response = await fetch("/api/sprints", { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + const responsePayload = await response.json().catch(() => null) + + if (!response.ok) { + throw new Error(toApiError(responsePayload, `${method} /api/sprints failed`)) + } + + await syncFromServer({ scope: "all" }) + setEditorOpen(false) + toast.success(editorMode === "create" ? "Sprint created" : "Sprint updated") + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to save sprint" + toast.error(message) + } finally { + setIsSaving(false) + } + } + + const handleDelete = async (sprintId: string) => { + setIsDeleting(true) + try { + const response = await fetch("/api/sprints", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: sprintId }), + }) + const responsePayload = await response.json().catch(() => null) + + if (!response.ok) { + throw new Error(toApiError(responsePayload, "DELETE /api/sprints failed")) + } + + await syncFromServer({ scope: "all" }) + setDeleteConfirmId(null) + toast.success("Sprint deleted") + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to delete sprint" + toast.error(message) + } finally { + setIsDeleting(false) + } + } + + const openOnBoard = (sprintId: string) => { + selectSprint(sprintId) + router.push("/") + } + + if (!authReady) { + return ( + <div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center"> + <p className="text-sm text-slate-400">Checking session...</p> + </div> + ) + } + + return ( + <div className="min-h-screen bg-slate-950 text-slate-100"> + <header className="border-b border-slate-800 bg-slate-900/50 backdrop-blur"> + <div className="max-w-[1800px] mx-auto px-4 py-4"> + <div className="flex items-center justify-between gap-4"> + <div className="flex items-center gap-4"> + <Button + variant="outline" + className="border-slate-700 text-slate-200 hover:bg-slate-800" + onClick={() => router.push("/")} + > + <ArrowLeft className="w-4 h-4 mr-2" /> + Back to Board + </Button> + <div> + <h1 className="text-xl md:text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent"> + Sprints + </h1> + <p className="text-xs md:text-sm text-slate-400 mt-1"> + View and manage sprint plans + </p> + </div> + </div> + <div className="flex items-center gap-2"> + <Button + variant="outline" + className="border-slate-700 text-slate-200 hover:bg-slate-800" + onClick={() => router.push("/sprints/archive")} + > + <Clock className="w-4 h-4 mr-2" /> + Archive + </Button> + <Button onClick={openCreateDialog}> + <Plus className="w-4 h-4 mr-2" /> + New Sprint + </Button> + </div> + </div> + </div> + </header> + + <div className="max-w-[1800px] mx-auto px-4 py-6 space-y-4"> + {syncError && ( + <div className="rounded-lg border border-red-500/30 bg-red-500/10 text-red-200 text-sm px-3 py-2"> + Sync error: {syncError} + </div> + )} + + <div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> + <Card className="bg-slate-900 border-slate-800"> + <CardContent className="p-4"> + <p className="text-sm text-slate-400">Total Sprints</p> + <p className="text-2xl font-semibold text-white mt-1">{sprints.length}</p> + </CardContent> + </Card> + <Card className="bg-slate-900 border-slate-800"> + <CardContent className="p-4"> + <p className="text-sm text-slate-400">Active</p> + <p className="text-2xl font-semibold text-emerald-300 mt-1"> + {sprints.filter((sprint) => sprint.status === "active").length} + </p> + </CardContent> + </Card> + <Card className="bg-slate-900 border-slate-800"> + <CardContent className="p-4"> + <p className="text-sm text-slate-400">Planning</p> + <p className="text-2xl font-semibold text-amber-300 mt-1"> + {sprints.filter((sprint) => sprint.status === "planning").length} + </p> + </CardContent> + </Card> + </div> + + {isLoading ? ( + <div className="flex items-center justify-center py-12 text-slate-400"> + <span>Loading sprints...</span> + </div> + ) : sortedSprints.length === 0 ? ( + <div className="flex flex-col items-center justify-center py-16 text-slate-400"> + <Calendar className="w-14 h-14 mb-4 text-slate-600" /> + <h3 className="text-lg font-medium text-slate-200 mb-2">No Sprints Yet</h3> + <p className="text-sm mb-4">Create your first sprint to start organizing work cycles.</p> + <Button onClick={openCreateDialog}> + <Plus className="w-4 h-4 mr-2" /> + Create Sprint + </Button> + </div> + ) : ( + <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> + {sortedSprints.map((sprint) => { + const counts = sprintTaskCounts.get(sprint.id) || { total: 0, done: 0 } + const completion = counts.total > 0 ? Math.round((counts.done / counts.total) * 100) : 0 + + return ( + <Card key={sprint.id} className="bg-slate-900 border-slate-800 hover:border-slate-600 transition-colors"> + <CardHeader className="pb-3"> + <div className="flex items-start justify-between gap-3"> + <div className="min-w-0"> + <h3 className="font-semibold text-white truncate">{sprint.name}</h3> + </div> + <Badge variant="outline" className={STATUS_BADGE_CLASSES[sprint.status]}> + {STATUS_LABELS[sprint.status]} + </Badge> + </div> + </CardHeader> + <CardContent className="space-y-4"> + {sprint.goal && <p className="text-sm text-slate-300 line-clamp-2">{sprint.goal}</p>} + + <div className="flex items-center gap-2 text-sm text-slate-400"> + <Calendar className="w-4 h-4" /> + <span>{formatDateRange(sprint.startDate, sprint.endDate)}</span> + </div> + + <div className="space-y-1"> + <div className="flex items-center justify-between text-xs text-slate-400"> + <span>Task completion</span> + <span>{counts.done}/{counts.total} ({completion}%)</span> + </div> + <div className="h-2 bg-slate-800 rounded-full overflow-hidden"> + <div + className="h-full bg-blue-500 transition-all" + style={{ width: `${completion}%` }} + /> + </div> + </div> + + <div className="flex items-center gap-2 pt-1"> + <Button + size="sm" + variant="outline" + className="border-slate-700 text-slate-200 hover:bg-slate-800" + onClick={() => openOnBoard(sprint.id)} + > + <CheckCircle2 className="w-3.5 h-3.5 mr-1.5" /> + Open + </Button> + <Button + size="sm" + variant="outline" + className="border-slate-700 text-slate-200 hover:bg-slate-800" + onClick={() => openEditDialog(sprint)} + > + <Pencil className="w-3.5 h-3.5 mr-1.5" /> + Edit + </Button> + <Button + size="sm" + variant="outline" + className="border-red-500/40 text-red-300 hover:bg-red-500/10" + onClick={() => setDeleteConfirmId(sprint.id)} + > + <Trash2 className="w-3.5 h-3.5 mr-1.5" /> + Delete + </Button> + </div> + </CardContent> + </Card> + ) + })} + </div> + )} + </div> + + <Dialog open={editorOpen} onOpenChange={setEditorOpen}> + <DialogContent className="bg-slate-900 border-slate-800 text-white w-[95vw] max-w-lg max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>{editorMode === "create" ? "Create Sprint" : "Edit Sprint"}</DialogTitle> + </DialogHeader> + + <div className="space-y-4 py-2"> + <div> + <label className="text-sm text-slate-400">Sprint Name *</label> + <Input + value={draft.name} + onChange={(event) => setDraft((prev) => ({ ...prev, name: event.target.value }))} + className="mt-1 bg-slate-800 border-slate-700 text-white" + placeholder="e.g., Sprint 5 - QA Hardening" + /> + </div> + + <div> + <label className="text-sm text-slate-400">Goal</label> + <Textarea + value={draft.goal} + onChange={(event) => setDraft((prev) => ({ ...prev, goal: event.target.value }))} + className="mt-1 bg-slate-800 border-slate-700 text-white" + rows={3} + placeholder="What should this sprint deliver?" + /> + </div> + + <div className="grid grid-cols-2 gap-3"> + <div> + <label className="text-sm text-slate-400">Start Date *</label> + <Input + type="date" + value={draft.startDate} + onChange={(event) => { + const startDate = event.target.value + setDraft((prev) => ({ + ...prev, + startDate, + status: inferSprintStatusForDateRange(startDate, prev.endDate), + })) + }} + className="mt-1 bg-slate-800 border-slate-700 text-white" + /> + </div> + <div> + <label className="text-sm text-slate-400">End Date *</label> + <Input + type="date" + value={draft.endDate} + onChange={(event) => { + const endDate = event.target.value + setDraft((prev) => ({ + ...prev, + endDate, + status: inferSprintStatusForDateRange(prev.startDate, endDate), + })) + }} + className="mt-1 bg-slate-800 border-slate-700 text-white" + /> + </div> + </div> + + <div> + <label className="text-sm text-slate-400">Status *</label> + <select + value={draft.status} + onChange={(event) => setDraft((prev) => ({ ...prev, status: event.target.value as SprintStatus }))} + className="w-full mt-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-md text-white" + > + <option value="planning">Planning</option> + <option value="active">Active</option> + <option value="completed">Completed</option> + </select> + </div> + + <div className="flex justify-end gap-2 pt-2"> + <Button + variant="outline" + className="border-slate-700 text-slate-200 hover:bg-slate-800" + onClick={() => setEditorOpen(false)} + disabled={isSaving} + > + Cancel + </Button> + <Button onClick={handleSave} disabled={isSaving}> + {isSaving ? "Saving..." : editorMode === "create" ? "Create Sprint" : "Save Changes"} + </Button> + </div> + </div> + </DialogContent> + </Dialog> + + <Dialog open={deleteConfirmId !== null} onOpenChange={(open) => !open && setDeleteConfirmId(null)}> + <DialogContent className="bg-slate-900 border-slate-800 text-white w-[95vw] max-w-md"> + <DialogHeader> + <DialogTitle>Delete Sprint?</DialogTitle> + </DialogHeader> + <p className="text-sm text-slate-300"> + This will remove the sprint and keep tasks in the backlog (their sprint assignment will be cleared). + </p> + <div className="flex justify-end gap-2 pt-2"> + <Button + variant="outline" + className="border-slate-700 text-slate-200 hover:bg-slate-800" + onClick={() => setDeleteConfirmId(null)} + disabled={isDeleting} + > + Cancel + </Button> + <Button + variant="destructive" + onClick={() => deleteConfirmId && handleDelete(deleteConfirmId)} + disabled={isDeleting} + > + {isDeleting ? "Deleting..." : "Delete Sprint"} + </Button> + </div> + </DialogContent> + </Dialog> + </div> + ) +} diff --git a/src/app/tasks/[taskId]/page.tsx b/src/app/tasks/[taskId]/page.tsx index ecb9000..ea5a98a 100644 --- a/src/app/tasks/[taskId]/page.tsx +++ b/src/app/tasks/[taskId]/page.tsx @@ -486,14 +486,9 @@ export default function TaskDetailPage() { const setEditedTaskProject = (projectId: string) => { if (!editedTask) return - - const sprintStillMatchesProject = - !editedTask.sprintId || sprints.some((sprint) => sprint.id === editedTask.sprintId && sprint.projectId === projectId) - setEditedTask({ ...editedTask, projectId, - sprintId: sprintStillMatchesProject ? editedTask.sprintId : undefined, }) } @@ -507,11 +502,9 @@ export default function TaskDetailPage() { return } - const sprint = sprints.find((entry) => entry.id === sprintId) setEditedTask({ ...editedTask, sprintId, - projectId: sprint?.projectId || editedTask.projectId, }) } diff --git a/src/components/BacklogView.tsx b/src/components/BacklogView.tsx index 81cffe0..6e43701 100644 --- a/src/components/BacklogView.tsx +++ b/src/components/BacklogView.tsx @@ -314,9 +314,7 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) { // Get current sprint using local-day boundaries (00:00 start, 23:59:59 end) const now = new Date() - const projectSprints = selectedProjectId - ? sprints.filter((s) => s.projectId === selectedProjectId) - : sprints + const projectSprints = sprints const projectTasks = selectedProjectId ? tasks.filter((t) => t.projectId === selectedProjectId) : tasks @@ -379,7 +377,7 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) { } const handleCreateSprint = () => { - if (!newSprint.name || !selectedProjectId) return + if (!newSprint.name) return const startDate = newSprint.startDate || toLocalDateInputValue() const endDate = newSprint.endDate || toLocalDateInputValue() @@ -389,7 +387,6 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) { startDate, endDate, status: inferSprintStatusForDateRange(startDate, endDate), - projectId: selectedProjectId, }) setIsCreatingSprint(false) diff --git a/src/components/SprintBoard.tsx b/src/components/SprintBoard.tsx index 71e6d18..4dbf6d9 100644 --- a/src/components/SprintBoard.tsx +++ b/src/components/SprintBoard.tsx @@ -163,7 +163,6 @@ export function SprintBoard() { sprints, selectedSprintId, selectSprint, - selectedProjectId, updateTask, selectTask, addSprint, @@ -187,10 +186,7 @@ export function SprintBoard() { }) ) - // Get sprints for selected project - const projectSprints = sprints.filter( - (s) => s.projectId === selectedProjectId - ) + const projectSprints = sprints // Get current sprint const currentSprint = sprints.find((s) => s.id === selectedSprintId) @@ -210,7 +206,7 @@ export function SprintBoard() { const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null const handleCreateSprint = () => { - if (!newSprint.name || !selectedProjectId) return + if (!newSprint.name) return const startDate = newSprint.startDate || toLocalDateInputValue() const endDate = newSprint.endDate || toLocalDateInputValue() @@ -220,7 +216,6 @@ export function SprintBoard() { startDate, endDate, status: inferSprintStatusForDateRange(startDate, endDate), - projectId: selectedProjectId, } addSprint(sprint) diff --git a/src/lib/server/sprintSelection.ts b/src/lib/server/sprintSelection.ts index d600e99..b5cfd4a 100644 --- a/src/lib/server/sprintSelection.ts +++ b/src/lib/server/sprintSelection.ts @@ -3,7 +3,6 @@ export interface SprintLike { startDate: string; endDate: string; status: "planning" | "active" | "completed"; - projectId?: string; } function parseSprintStart(value: string): Date { @@ -34,16 +33,12 @@ export function findCurrentSprint<T extends SprintLike>( sprints: T[], options?: { now?: Date - projectId?: string includeCompletedFallback?: boolean } ): T | null { const now = options?.now ?? new Date() const includeCompletedFallback = options?.includeCompletedFallback ?? false - const projectId = options?.projectId - - const scoped = projectId ? sprints.filter((s) => s.projectId === projectId) : sprints - const inProgress = scoped.filter((s) => isSprintInProgress(s.startDate, s.endDate, now)) + const inProgress = sprints.filter((s) => isSprintInProgress(s.startDate, s.endDate, now)) return ( inProgress.find((s) => s.status === "active") ?? diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index a73a162..c6c30f0 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -132,7 +132,6 @@ export interface Database { start_date: string; end_date: string; status: 'planning' | 'active' | 'completed'; - project_id: string; created_at: string; }; Insert: { @@ -142,7 +141,6 @@ export interface Database { start_date: string; end_date: string; status: 'planning' | 'active' | 'completed'; - project_id: string; created_at?: string; }; Update: { @@ -152,7 +150,6 @@ export interface Database { start_date?: string; end_date?: string; status?: 'planning' | 'active' | 'completed'; - project_id?: string; created_at?: string; }; }; diff --git a/src/stores/useTaskStore.ts b/src/stores/useTaskStore.ts index b122372..d8dc976 100644 --- a/src/stores/useTaskStore.ts +++ b/src/stores/useTaskStore.ts @@ -14,7 +14,6 @@ export interface Sprint { startDate: string endDate: string status: SprintStatus - projectId: string createdAt: string } diff --git a/supabase/nightly_sprint_rollover.sql b/supabase/nightly_sprint_rollover.sql index b1503a7..5783042 100644 --- a/supabase/nightly_sprint_rollover.sql +++ b/supabase/nightly_sprint_rollover.sql @@ -14,18 +14,17 @@ DECLARE next_sprint_id UUID; BEGIN FOR ended_sprint IN - SELECT id, project_id + SELECT id FROM public.sprints WHERE status <> 'completed' AND end_date < CURRENT_DATE ORDER BY end_date ASC, start_date ASC LOOP - -- Pick the next non-completed sprint in the same project. + -- Pick the next non-completed sprint globally. SELECT s.id INTO next_sprint_id FROM public.sprints s - WHERE s.project_id = ended_sprint.project_id - AND s.status <> 'completed' + WHERE s.status <> 'completed' AND s.id <> ended_sprint.id ORDER BY s.start_date ASC LIMIT 1; @@ -66,4 +65,3 @@ BEGIN ); END; $$; - diff --git a/supabase/remove_sprint_project_id.sql b/supabase/remove_sprint_project_id.sql new file mode 100644 index 0000000..f044bee --- /dev/null +++ b/supabase/remove_sprint_project_id.sql @@ -0,0 +1,49 @@ +-- Removes sprint -> project coupling. +-- Run once against the existing database before using sprint create/update flows. + +BEGIN; + +DROP INDEX IF EXISTS idx_sprints_project_id; +ALTER TABLE public.sprints DROP CONSTRAINT IF EXISTS sprints_project_id_fkey; +ALTER TABLE public.sprints DROP COLUMN IF EXISTS project_id; + +CREATE OR REPLACE FUNCTION public.complete_ended_sprints_and_rollover() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + ended_sprint RECORD; + next_sprint_id UUID; +BEGIN + FOR ended_sprint IN + SELECT id + FROM public.sprints + WHERE status <> 'completed' + AND end_date < CURRENT_DATE + ORDER BY end_date ASC, start_date ASC + LOOP + SELECT s.id + INTO next_sprint_id + FROM public.sprints s + WHERE s.status <> 'completed' + AND s.id <> ended_sprint.id + ORDER BY s.start_date ASC + LIMIT 1; + + IF next_sprint_id IS NOT NULL THEN + UPDATE public.tasks + SET sprint_id = next_sprint_id + WHERE sprint_id = ended_sprint.id + AND status NOT IN ('done', 'canceled', 'archived'); + END IF; + + UPDATE public.sprints + SET status = 'completed' + WHERE id = ended_sprint.id; + END LOOP; +END; +$$; + +COMMIT; diff --git a/supabase/schema.sql b/supabase/schema.sql index 4dbb96b..2c9cbc7 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -123,12 +123,10 @@ CREATE TABLE IF NOT EXISTS sprints ( start_date DATE NOT NULL, end_date DATE NOT NULL, status TEXT NOT NULL CHECK (status IN ('planning', 'active', 'completed')), - project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Create indexes -CREATE INDEX IF NOT EXISTS idx_sprints_project_id ON sprints(project_id); CREATE INDEX IF NOT EXISTS idx_sprints_legacy_id ON sprints(legacy_id); CREATE INDEX IF NOT EXISTS idx_sprints_dates ON sprints(start_date, end_date); @@ -261,18 +259,17 @@ DECLARE next_sprint_id UUID; BEGIN FOR ended_sprint IN - SELECT id, project_id + SELECT id FROM public.sprints WHERE status <> 'completed' AND end_date < CURRENT_DATE ORDER BY end_date ASC, start_date ASC LOOP - -- Pick the next non-completed sprint in the same project. + -- Pick the next non-completed sprint globally. SELECT s.id INTO next_sprint_id FROM public.sprints s - WHERE s.project_id = ended_sprint.project_id - AND s.status <> 'completed' + WHERE s.status <> 'completed' AND s.id <> ended_sprint.id ORDER BY s.start_date ASC LIMIT 1;