Signed-off-by: Max <ai-agent@topdoglabs.com>

This commit is contained in:
Max 2026-02-25 13:36:03 -06:00
parent c571130029
commit 95fe894ed4
25 changed files with 739 additions and 255 deletions

View File

@ -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 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). - 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. - API validation now accepts UUID-shaped IDs used by existing data and returns clearer error payloads for failed writes.
### Feb 24, 2026 updates ### 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 - sprint selection unit tests
- mocked CLI API-contract tests across task/project/sprint/auth/debug command paths - 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 date semantics (important)
- Sprint dates are treated as local calendar-day boundaries: - 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). - 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`). - Normalizes and persists only the date part (`YYYY-MM-DD`).
- `PATCH` returns `400` for invalid `startDate`/`endDate` format. - `PATCH` returns `400` for invalid `startDate`/`endDate` format.
- Example request payloads: - Example request payloads:
- `POST /api/sprints` - `POST /api/sprints`
- `{ "name": "Sprint 1", "startDate": "2026-02-16", "endDate": "2026-02-22", "status": "active", "projectId": "<uuid>" }` - `{ "name": "Sprint 1", "startDate": "2026-02-16", "endDate": "2026-02-22", "status": "active" }`
- `PATCH /api/sprints` - `PATCH /api/sprints`
- `{ "id": "<sprint-id>", "startDate": "2026-02-16T00:00:00.000Z", "endDate": "2026-02-22T23:59:59.999Z" }` - `{ "id": "<sprint-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'` - persisted as `start_date = '2026-02-16'`, `end_date = '2026-02-22'`

View File

@ -51,7 +51,7 @@ Authenticate once:
```bash ```bash
./task.sh list [status] [--status <v>] [--priority <v>] [--project <name-or-id>] [--assignee <name-or-id>] [--type <v>] [--limit <n>] [--json] ./task.sh list [status] [--status <v>] [--priority <v>] [--project <name-or-id>] [--assignee <name-or-id>] [--type <v>] [--limit <n>] [--json]
./task.sh get <task-id> ./task.sh get <task-id>
./task.sh current-sprint [--project <name-or-id>] ./task.sh current-sprint
./task.sh create --title "<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 --title "<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 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"] ./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` ## `sprint.sh`
```bash ```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 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 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>] [--project <name-or-id>] ./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 close <sprint-id-or-name>
./sprint.sh delete <sprint-id-or-name> ./sprint.sh delete <sprint-id-or-name>
``` ```
Sprints are global and no longer accept `project` filters/fields.
## `gantt.sh` ## `gantt.sh`
High-level wrapper for full API surface: 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` - project names -> `projectId`
- sprint names -> `sprintId` - sprint names -> `sprintId`
- assignee names/emails -> `assigneeId` - 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 ## Testing

View File

@ -8,7 +8,7 @@ set -e
# Configuration # Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" 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}" COOKIE_FILE="${GANTT_COOKIE_FILE:-$HOME/.config/gantt-board/cookies.txt}"
# Colors for output # Colors for output
@ -381,13 +381,12 @@ cmd_sprint_get() {
cmd_sprint_create() { cmd_sprint_create() {
local name="$1" local name="$1"
local project_id="$2" local start_date="${2:-}"
local start_date="${3:-}" local end_date="${3:-}"
local end_date="${4:-}" local goal="${4:-}"
local goal="${5:-}"
if [ -z "$name" ]; then 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 exit 1
fi fi
@ -395,13 +394,11 @@ cmd_sprint_create() {
local data local data
data=$(jq -n \ data=$(jq -n \
--arg name "$name" \ --arg name "$name" \
--arg projectId "$project_id" \
--arg startDate "$start_date" \ --arg startDate "$start_date" \
--arg endDate "$end_date" \ --arg endDate "$end_date" \
--arg goal "$goal" \ --arg goal "$goal" \
'{ '{
name: $name, name: $name,
projectId: $projectId,
startDate: (if $startDate == "" then null else $startDate end), startDate: (if $startDate == "" then null else $startDate end),
endDate: (if $endDate == "" then null else $endDate end), endDate: (if $endDate == "" then null else $endDate end),
goal: (if $goal == "" then null else $goal end), goal: (if $goal == "" then null else $goal end),
@ -417,7 +414,7 @@ cmd_sprint_update() {
if [ -z "$sprint_id" ] || [ -z "$field" ]; then if [ -z "$sprint_id" ] || [ -z "$field" ]; then
log_error "Usage: sprint update <id> <field> <value>" 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 exit 1
fi fi
@ -590,12 +587,12 @@ PROJECT COMMANDS:
SPRINT COMMANDS: SPRINT COMMANDS:
sprint list List all sprints sprint list List all sprints
sprint get <id> Get specific sprint sprint get <id> Get specific sprint
sprint create <name> <project-id> [start] [end] [goal] sprint create <name> [start] [end] [goal]
Create new sprint Create new sprint
sprint update <id> <field> <val> sprint update <id> <field> <val>
Update sprint field Update sprint field
Fields: name, goal, startDate, endDate, Fields: name, goal, startDate, endDate,
status, projectId status
sprint close <id> Close a sprint sprint close <id> Close a sprint
sprint delete <id> Delete a sprint sprint delete <id> Delete a sprint

View File

@ -1,6 +1,6 @@
#!/bin/bash #!/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}" COOKIE_FILE="${GANTT_COOKIE_FILE:-$HOME/.config/gantt-board/cookies.txt}"
ensure_cookie_store() { ensure_cookie_store() {
@ -12,12 +12,46 @@ urlencode() {
jq -rn --arg v "$1" '$v|@uri' 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() { api_call() {
local method="$1" local method="$1"
local endpoint="$2" local endpoint="$2"
local data="${3:-}" local data="${3:-}"
ensure_cookie_store ensure_cookie_store
login_if_needed || return 1
local url="${API_URL}${endpoint}" 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") local curl_opts=(-sS -w "\n%{http_code}" -X "$method" "$url" -H "Content-Type: application/json" -b "$COOKIE_FILE" -c "$COOKIE_FILE")

View File

@ -45,7 +45,6 @@ COMMANDS:
CREATE OPTIONS: CREATE OPTIONS:
--name "Name" Sprint name (required) --name "Name" Sprint name (required)
--project "Project" Project name or ID (required)
--goal "Goal" Sprint goal --goal "Goal" Sprint goal
--start-date "YYYY-MM-DD" Start date --start-date "YYYY-MM-DD" Start date
--end-date "YYYY-MM-DD" End date --end-date "YYYY-MM-DD" End date
@ -53,39 +52,11 @@ CREATE OPTIONS:
LIST OPTIONS: LIST OPTIONS:
--status <status> Filter by status --status <status> Filter by status
--project <project> Filter by project name/ID --active Show sprints in progress for today (start <= today <= end)
--active Show only active sprints
--json Output as JSON --json Output as JSON
USAGE 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() { resolve_sprint_id() {
local identifier="$1" local identifier="$1"
@ -115,7 +86,6 @@ resolve_sprint_id() {
cmd_create() { cmd_create() {
local name="" local name=""
local project=""
local goal="" local goal=""
local start_date="" local start_date=""
local end_date="" local end_date=""
@ -124,7 +94,6 @@ cmd_create() {
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "${1:-}" in case "${1:-}" in
--name) name="${2:-}"; shift 2 ;; --name) name="${2:-}"; shift 2 ;;
--project) project="${2:-}"; shift 2 ;;
--goal) goal="${2:-}"; shift 2 ;; --goal) goal="${2:-}"; shift 2 ;;
--start-date) start_date="${2:-}"; shift 2 ;; --start-date) start_date="${2:-}"; shift 2 ;;
--end-date) end_date="${2:-}"; shift 2 ;; --end-date) end_date="${2:-}"; shift 2 ;;
@ -138,25 +107,15 @@ cmd_create() {
exit 1 exit 1
fi 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 local payload
payload=$(jq -n \ payload=$(jq -n \
--arg name "$name" \ --arg name "$name" \
--arg projectId "$project_id" \
--arg goal "$goal" \ --arg goal "$goal" \
--arg startDate "$start_date" \ --arg startDate "$start_date" \
--arg endDate "$end_date" \ --arg endDate "$end_date" \
--arg status "$status" \ --arg status "$status" \
'{ '{
name: $name, name: $name,
projectId: $projectId,
goal: (if $goal == "" then null else $goal end), goal: (if $goal == "" then null else $goal end),
startDate: (if $startDate == "" then null else $startDate end), startDate: (if $startDate == "" then null else $startDate end),
endDate: (if $endDate == "" then null else $endDate end), endDate: (if $endDate == "" then null else $endDate end),
@ -169,27 +128,31 @@ cmd_create() {
cmd_list() { cmd_list() {
local filter_status="" local filter_status=""
local filter_project=""
local active_only=false local active_only=false
local output_json=false local output_json=false
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "${1:-}" in case "${1:-}" in
--status) filter_status="${2:-}"; shift 2 ;; --status) filter_status="${2:-}"; shift 2 ;;
--project) filter_project="${2:-}"; shift 2 ;;
--active) active_only=true; shift ;; --active) active_only=true; shift ;;
--json) output_json=true; shift ;; --json) output_json=true; shift ;;
*) shift ;; *) shift ;;
esac esac
done done
if [[ "$active_only" == true ]]; then
filter_status="active"
fi
local endpoint="/sprints" local endpoint="/sprints"
local query_params=()
if [[ -n "$filter_status" ]]; then 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 fi
local response local response
@ -198,12 +161,6 @@ cmd_list() {
local sprints_json local sprints_json
sprints_json=$(echo "$response" | jq '.sprints') 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 if [[ "$output_json" == true ]]; then
echo "$sprints_json" | jq . echo "$sprints_json" | jq .
return return
@ -256,12 +213,6 @@ cmd_update() {
--start-date) updates=$(echo "$updates" | jq --arg v "${2:-}" '. + {startDate: $v}'); shift 2 ;; --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 ;; --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 ;; --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 ;; *) shift ;;
esac esac
done done

View File

@ -49,7 +49,7 @@ COMMANDS:
update <task-id> <field> <value> Update one field (legacy) update <task-id> <field> <value> Update one field (legacy)
update <task-id> [flags...] Update task with flags update <task-id> [flags...] Update task with flags
delete <task-id> Delete task 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 bulk-create <json-file> Bulk create from JSON array
USAGE USAGE
} }
@ -131,7 +131,6 @@ resolve_assignee_id() {
resolve_sprint_id() { resolve_sprint_id() {
local identifier="$1" local identifier="$1"
local project_identifier="${2:-}"
if [[ -z "$identifier" ]]; then if [[ -z "$identifier" ]]; then
echo "" echo ""
@ -139,16 +138,8 @@ resolve_sprint_id() {
fi fi
if [[ "$identifier" == "current" ]]; then 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 local response
response=$(api_call GET "$endpoint") response=$(api_call GET "/sprints/current")
echo "$response" | jq -r '.sprint.id // empty' echo "$response" | jq -r '.sprint.id // empty'
return 0 return 0
fi fi
@ -177,18 +168,8 @@ resolve_sprint_id() {
} }
get_current_sprint() { 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 local response
response=$(api_call GET "$endpoint") response=$(api_call GET "/sprints/current")
echo "$response" | jq -r '.sprint.id // empty' echo "$response" | jq -r '.sprint.id // empty'
} }
@ -387,7 +368,7 @@ create_task() {
assignee_id=$(resolve_assignee_id "$assignee") assignee_id=$(resolve_assignee_id "$assignee")
local sprint_id local sprint_id
sprint_id=$(resolve_sprint_id "$sprint" "$project") sprint_id=$(resolve_sprint_id "$sprint")
local tags_json local tags_json
tags_json=$(to_tag_array "$tags_csv") tags_json=$(to_tag_array "$tags_csv")
@ -486,10 +467,8 @@ update_task() {
;; ;;
--sprint) --sprint)
local sprint_input="${2:-}" local sprint_input="${2:-}"
local project_hint
project_hint=$(echo "$existing" | jq -r '.projectId // ""')
local sprint_id 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)') existing=$(echo "$existing" | jq --arg v "$sprint_id" '.sprintId = (if $v == "" then null else $v end)')
shift 2 shift 2
;; ;;
@ -594,11 +573,7 @@ case "${1:-}" in
;; ;;
current-sprint) current-sprint)
shift shift
local_project="" get_current_sprint
if [[ "${1:-}" == "--project" ]]; then
local_project="${2:-}"
fi
get_current_sprint "$local_project"
;; ;;
bulk-create) bulk-create)
shift shift

View File

@ -106,14 +106,14 @@ case "${method} ${url}" in
respond '{"success":true}' 200 respond '{"success":true}' 200
;; ;;
"GET http://localhost:3000/api/sprints"|\ "GET http://localhost:3000/api/sprints"|\
"GET http://localhost:3000/api/sprints?status=active") "GET http://localhost:3000/api/sprints?inProgress=true&onDate="*)
respond '{"sprints":[{"id":"s1","name":"Sprint 1","projectId":"p1","status":"active","startDate":"2026-02-20","endDate":"2026-03-01"}]}' 200 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") "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") "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") "PATCH http://localhost:3000/api/sprints")
respond '{"success":true}' 200 respond '{"success":true}' 200
@ -124,9 +124,6 @@ case "${method} ${url}" in
"GET http://localhost:3000/api/sprints/current") "GET http://localhost:3000/api/sprints/current")
respond '{"sprint":{"id":"s1","name":"Sprint 1"}}' 200 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") "POST http://localhost:3000/api/sprints/close")
respond '{"success":true}' 200 respond '{"success":true}' 200
;; ;;
@ -191,7 +188,6 @@ BULK_EOF
"$ROOT_DIR/scripts/task.sh" list --json >/dev/null "$ROOT_DIR/scripts/task.sh" list --json >/dev/null
"$ROOT_DIR/scripts/task.sh" get t1 >/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 >/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" 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" update t1 --status done --tags "api,refactor" --add-comment "done" >/dev/null
"$ROOT_DIR/scripts/task.sh" delete t1 >/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 --json >/dev/null
"$ROOT_DIR/scripts/sprint.sh" list --active --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" 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" update "Sprint 1" --status completed >/dev/null
"$ROOT_DIR/scripts/sprint.sh" close "Sprint 1" >/dev/null "$ROOT_DIR/scripts/sprint.sh" close "Sprint 1" >/dev/null
"$ROOT_DIR/scripts/sprint.sh" delete "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 list >/dev/null
"$ROOT_DIR/scripts/gantt.sh" sprint get s1 >/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 update s1 status completed >/dev/null
"$ROOT_DIR/scripts/gantt.sh" sprint close s1 >/dev/null "$ROOT_DIR/scripts/gantt.sh" sprint close s1 >/dev/null
"$ROOT_DIR/scripts/gantt.sh" sprint delete 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/projects/p1"
assert_log_contains "GET http://localhost:3000/api/sprints" 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/s1"
assert_log_contains "GET http://localhost:3000/api/sprints/current" 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 "POST http://localhost:3000/api/sprints"
assert_log_contains "PATCH http://localhost:3000/api/sprints" assert_log_contains "PATCH http://localhost:3000/api/sprints"
assert_log_contains "POST http://localhost:3000/api/sprints/close" assert_log_contains "POST http://localhost:3000/api/sprints/close"

View File

@ -13,10 +13,10 @@ test("isSprintInProgress uses inclusive boundaries", () => {
test("findCurrentSprint prefers active sprint in range", () => { test("findCurrentSprint prefers active sprint in range", () => {
const sprint = findCurrentSprint( const sprint = findCurrentSprint(
[ [
{ id: "planning", status: "planning", startDate: "2026-02-20", endDate: "2026-02-28", 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", projectId: "p1" }, { id: "active", status: "active", startDate: "2026-02-22", endDate: "2026-02-26" },
], ],
{ now: NOW, projectId: "p1" } { now: NOW }
) )
assert.equal(sprint?.id, "active") 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", () => { test("findCurrentSprint falls back to non-completed in range", () => {
const sprint = findCurrentSprint( const sprint = findCurrentSprint(
[{ id: "planning", status: "planning", startDate: "2026-02-20", endDate: "2026-02-28", projectId: "p1" }], [{ id: "planning", status: "planning", startDate: "2026-02-20", endDate: "2026-02-28" }],
{ now: NOW, projectId: "p1" } { now: NOW }
) )
assert.equal(sprint?.id, "planning") assert.equal(sprint?.id, "planning")
}) })
test("findCurrentSprint returns null for completed-only unless fallback enabled", () => { 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 withoutFallback = findCurrentSprint(sprints, { now: NOW })
const withFallback = findCurrentSprint(sprints, { now: NOW, projectId: "p1", includeCompletedFallback: true }) const withFallback = findCurrentSprint(sprints, { now: NOW, includeCompletedFallback: true })
assert.equal(withoutFallback, null) assert.equal(withoutFallback, null)
assert.equal(withFallback?.id, "done") assert.equal(withFallback?.id, "done")
}) })
test("findCurrentSprint respects project scoping", () => { test("findCurrentSprint is global across all sprints", () => {
const sprint = findCurrentSprint( const sprint = findCurrentSprint(
[ [
{ id: "p1-active", status: "active", startDate: "2026-02-20", endDate: "2026-02-28", projectId: "p1" }, { id: "planning", status: "planning", startDate: "2026-02-20", endDate: "2026-02-28" },
{ id: "p2-active", status: "active", startDate: "2026-02-20", endDate: "2026-02-28", projectId: "p2" }, { 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", () => { test("findCurrentSprint returns null when no sprint is in range", () => {
const sprint = findCurrentSprint( const sprint = findCurrentSprint(
[{ id: "future", status: "active", startDate: "2026-03-01", endDate: "2026-03-07", projectId: "p1" }], [{ id: "future", status: "active", startDate: "2026-03-01", endDate: "2026-03-07" }],
{ now: NOW, projectId: "p1" } { now: NOW }
) )
assert.equal(sprint, null) assert.equal(sprint, null)

View File

@ -10,8 +10,8 @@ const mockProjects = [
] ]
const mockSprints = [ const mockSprints = [
{ id: 'sprint-1', name: 'Sprint 1', projectId: 'proj-1', startDate: '2026-02-01', endDate: '2026-02-14', status: 'active' }, { id: 'sprint-1', name: 'Sprint 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-2', name: 'Sprint 2', startDate: '2026-02-15', endDate: '2026-02-28', status: 'planned' },
] ]
describe('Project Selector in New Task Dialog', () => { describe('Project Selector in New Task Dialog', () => {
@ -44,9 +44,9 @@ describe('Project Selector in New Task Dialog', () => {
// Verified in handleAddTask: const targetProjectId = selectedProjectFromTask || ... // Verified in handleAddTask: const targetProjectId = selectedProjectFromTask || ...
}) })
it('should fall back to sprint project when no explicit selection', () => { it('should not infer project from sprint selection', () => {
// Priority 2: selectedSprint?.projectId // Sprint selection is independent from project selection.
// Verified: || selectedSprint?.projectId // Tasks are always assigned to an explicit or selected project.
}) })
it('should fall back to current selection', () => { it('should fall back to current selection', () => {

View File

@ -5,8 +5,6 @@ import { findCurrentSprint } from "@/lib/server/sprintSelection"
export const runtime = "nodejs" 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) { export async function GET(request: Request) {
try { try {
const user = await getAuthenticatedUser() const user = await getAuthenticatedUser()
@ -16,16 +14,11 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const includeCompletedFallback = searchParams.get("includeCompletedFallback") === "true" 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 supabase = getServiceSupabase()
const { data, error } = await supabase const { data, error } = await supabase
.from("sprints") .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 }) .order("start_date", { ascending: true })
if (error) throw error if (error) throw error
@ -37,11 +30,10 @@ export async function GET(request: Request) {
startDate: row.start_date, startDate: row.start_date,
endDate: row.end_date, endDate: row.end_date,
status: row.status, status: row.status,
projectId: row.project_id,
createdAt: row.created_at, createdAt: row.created_at,
})) }))
const sprint = findCurrentSprint(mapped, { projectId: projectId || undefined, includeCompletedFallback }) const sprint = findCurrentSprint(mapped, { includeCompletedFallback })
return NextResponse.json({ sprint }) return NextResponse.json({ sprint })
} catch (error) { } catch (error) {
console.error(">>> API GET /sprints/current error:", error) console.error(">>> API GET /sprints/current error:", error)

View File

@ -9,6 +9,8 @@ export const runtime = "nodejs";
const DATE_PREFIX_PATTERN = /^(\d{4}-\d{2}-\d{2})/; 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 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 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]; type SprintStatus = (typeof SPRINT_STATUSES)[number];
class HttpError extends Error { class HttpError extends Error {
@ -30,6 +32,14 @@ function toDateOnlyInput(value: unknown): string | null {
return match?.[1] ?? 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 { function requireNonEmptyString(value: unknown, field: string): string {
if (typeof value !== "string" || value.trim().length === 0) { if (typeof value !== "string" || value.trim().length === 0) {
throw new HttpError(400, `${field} is required`, { field, value }); throw new HttpError(400, `${field} is required`, { field, value });
@ -52,18 +62,6 @@ function requireSprintStatus(value: unknown, field: string): SprintStatus {
return value as 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) // GET - fetch all sprints (optionally filtered by status)
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
@ -75,9 +73,15 @@ export async function GET(request: Request) {
// Parse query params // Parse query params
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const status = searchParams.get("status"); 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)) { if (status && !SPRINT_STATUSES.includes(status as SprintStatus)) {
throw new HttpError(400, `status must be one of: ${SPRINT_STATUSES.join(", ")}`, { status }); 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(); const supabase = getServiceSupabase();
let query = supabase let query = supabase
@ -89,6 +93,10 @@ export async function GET(request: Request) {
if (status) { if (status) {
query = query.eq("status", 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; const { data: sprints, error } = await query;
@ -113,7 +121,7 @@ export async function POST(request: Request) {
} }
const body = await request.json(); const body = await request.json();
const { name, goal, startDate, endDate, status, projectId } = body; const { name, goal, startDate, endDate, status } = body;
const supabase = getServiceSupabase(); const supabase = getServiceSupabase();
const resolvedName = requireNonEmptyString(name, "name"); 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 }); throw new HttpError(400, "endDate must be YYYY-MM-DD or ISO date-time", { endDate });
} }
const resolvedStatus = requireSprintStatus(status, "status"); const resolvedStatus = requireSprintStatus(status, "status");
const resolvedProjectId = await requireExistingProjectId(
supabase,
requireUuid(projectId, "projectId")
);
const now = new Date().toISOString(); const now = new Date().toISOString();
const { data, error } = await supabase const { data, error } = await supabase
@ -140,7 +144,6 @@ export async function POST(request: Request) {
start_date: normalizedStartDate, start_date: normalizedStartDate,
end_date: normalizedEndDate, end_date: normalizedEndDate,
status: resolvedStatus, status: resolvedStatus,
project_id: resolvedProjectId,
created_at: now, created_at: now,
}) })
.select() .select()
@ -191,10 +194,6 @@ export async function PATCH(request: Request) {
dbUpdates.end_date = normalizedEndDate; dbUpdates.end_date = normalizedEndDate;
} }
if (updates.status !== undefined) dbUpdates.status = requireSprintStatus(updates.status, "status"); 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 const { data, error } = await supabase
.from("sprints") .from("sprints")

View File

@ -47,7 +47,6 @@ interface Sprint {
startDate: string; startDate: string;
endDate: string; endDate: string;
status: "planning" | "active" | "completed"; status: "planning" | "active" | "completed";
projectId: string;
createdAt: string; createdAt: string;
} }
@ -211,7 +210,6 @@ function mapSprintRow(row: Record<string, unknown>): Sprint {
startDate: requireNonEmptyString(row.start_date, "sprints.start_date", 500), startDate: requireNonEmptyString(row.start_date, "sprints.start_date", 500),
endDate: requireNonEmptyString(row.end_date, "sprints.end_date", 500), endDate: requireNonEmptyString(row.end_date, "sprints.end_date", 500),
status: requireEnum(row.status, SPRINT_STATUSES, "sprints.status", 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), createdAt: requireNonEmptyString(row.created_at, "sprints.created_at", 500),
}; };
} }
@ -306,7 +304,7 @@ export async function GET(request: Request) {
{ data: users, error: usersError } { data: users, error: usersError }
] = await Promise.all([ ] = await Promise.all([
supabase.from("projects").select("id, name, description, color, created_at").order("created_at", { ascending: true }), 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"), supabase.from("users").select("id, name, email, avatar_url"),
]); ]);

View File

@ -853,10 +853,9 @@ export default function Home() {
const handleAddTask = () => { const handleAddTask = () => {
if (newTask.title?.trim()) { 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 selectedProjectFromTask = newTask.projectId
const selectedSprint = newTask.sprintId ? sprints.find(s => s.id === newTask.sprintId) : null const targetProjectId = selectedProjectFromTask || selectedProjectId || projects[0]?.id
const targetProjectId = selectedProjectFromTask || selectedSprint?.projectId || selectedProjectId || projects[0]?.id
if (!targetProjectId) { if (!targetProjectId) {
toast.error("Cannot create task", { toast.error("Cannot create task", {
description: "No project is available. Create or select a project first.", 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 })} 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" 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 {projects
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.map((project) => ( .map((project) => (

View File

@ -40,7 +40,6 @@ import { Textarea } from "@/components/ui/textarea"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { useTaskStore, Task, Project } from "@/stores/useTaskStore" import { useTaskStore, Task, Project } from "@/stores/useTaskStore"
import { toast } from "sonner" import { toast } from "sonner"
import { parseSprintStart } from "@/lib/utils"
const PRESET_COLORS = [ const PRESET_COLORS = [
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6", "#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6",
@ -228,7 +227,6 @@ export default function ProjectDetailPage() {
const { const {
projects, projects,
tasks, tasks,
sprints,
currentUser, currentUser,
updateTask, updateTask,
updateProject, updateProject,
@ -318,13 +316,6 @@ export default function ProjectDetailPage() {
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
}, [tasks, currentUser.id]) }, [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 // Stats
const stats = useMemo(() => { const stats = useMemo(() => {
const total = projectTasks.length const total = projectTasks.length
@ -637,21 +628,6 @@ export default function ProjectDetailPage() {
</Badge> </Badge>
</div> </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}> <SortableContext items={projectTasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2 min-h-[200px]"> <div className="space-y-2 min-h-[200px]">
{projectTasks.length === 0 ? ( {projectTasks.length === 0 ? (

View File

@ -17,7 +17,6 @@ interface Sprint {
startDate: string startDate: string
endDate: string endDate: string
status: "planning" | "active" | "completed" status: "planning" | "active" | "completed"
projectId: string
createdAt: string createdAt: string
} }
@ -49,7 +48,6 @@ interface SprintDetail {
type SprintInput = Partial<Sprint> & { type SprintInput = Partial<Sprint> & {
start_date?: string start_date?: string
end_date?: string end_date?: string
project_id?: string
created_at?: 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" const status = input.status === "planning" || input.status === "active" || input.status === "completed"
? input.status ? input.status
: inferSprintStatusForDateRange(startDate, endDate) : 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" const createdAt = typeof input.createdAt === "string"
? input.createdAt ? input.createdAt
: typeof input.created_at === "string" : typeof input.created_at === "string"
@ -91,7 +84,6 @@ function normalizeSprint(input: SprintInput | null | undefined): Sprint | null {
startDate, startDate,
endDate, endDate,
status, status,
projectId,
createdAt, createdAt,
} }
} }

545
src/app/sprints/page.tsx Normal file
View File

@ -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>
)
}

View File

@ -486,14 +486,9 @@ export default function TaskDetailPage() {
const setEditedTaskProject = (projectId: string) => { const setEditedTaskProject = (projectId: string) => {
if (!editedTask) return if (!editedTask) return
const sprintStillMatchesProject =
!editedTask.sprintId || sprints.some((sprint) => sprint.id === editedTask.sprintId && sprint.projectId === projectId)
setEditedTask({ setEditedTask({
...editedTask, ...editedTask,
projectId, projectId,
sprintId: sprintStillMatchesProject ? editedTask.sprintId : undefined,
}) })
} }
@ -507,11 +502,9 @@ export default function TaskDetailPage() {
return return
} }
const sprint = sprints.find((entry) => entry.id === sprintId)
setEditedTask({ setEditedTask({
...editedTask, ...editedTask,
sprintId, sprintId,
projectId: sprint?.projectId || editedTask.projectId,
}) })
} }

View File

@ -314,9 +314,7 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
// Get current sprint using local-day boundaries (00:00 start, 23:59:59 end) // Get current sprint using local-day boundaries (00:00 start, 23:59:59 end)
const now = new Date() const now = new Date()
const projectSprints = selectedProjectId const projectSprints = sprints
? sprints.filter((s) => s.projectId === selectedProjectId)
: sprints
const projectTasks = selectedProjectId const projectTasks = selectedProjectId
? tasks.filter((t) => t.projectId === selectedProjectId) ? tasks.filter((t) => t.projectId === selectedProjectId)
: tasks : tasks
@ -379,7 +377,7 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
} }
const handleCreateSprint = () => { const handleCreateSprint = () => {
if (!newSprint.name || !selectedProjectId) return if (!newSprint.name) return
const startDate = newSprint.startDate || toLocalDateInputValue() const startDate = newSprint.startDate || toLocalDateInputValue()
const endDate = newSprint.endDate || toLocalDateInputValue() const endDate = newSprint.endDate || toLocalDateInputValue()
@ -389,7 +387,6 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
startDate, startDate,
endDate, endDate,
status: inferSprintStatusForDateRange(startDate, endDate), status: inferSprintStatusForDateRange(startDate, endDate),
projectId: selectedProjectId,
}) })
setIsCreatingSprint(false) setIsCreatingSprint(false)

View File

@ -163,7 +163,6 @@ export function SprintBoard() {
sprints, sprints,
selectedSprintId, selectedSprintId,
selectSprint, selectSprint,
selectedProjectId,
updateTask, updateTask,
selectTask, selectTask,
addSprint, addSprint,
@ -187,10 +186,7 @@ export function SprintBoard() {
}) })
) )
// Get sprints for selected project const projectSprints = sprints
const projectSprints = sprints.filter(
(s) => s.projectId === selectedProjectId
)
// Get current sprint // Get current sprint
const currentSprint = sprints.find((s) => s.id === selectedSprintId) 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 activeTask = activeId ? tasks.find((t) => t.id === activeId) : null
const handleCreateSprint = () => { const handleCreateSprint = () => {
if (!newSprint.name || !selectedProjectId) return if (!newSprint.name) return
const startDate = newSprint.startDate || toLocalDateInputValue() const startDate = newSprint.startDate || toLocalDateInputValue()
const endDate = newSprint.endDate || toLocalDateInputValue() const endDate = newSprint.endDate || toLocalDateInputValue()
@ -220,7 +216,6 @@ export function SprintBoard() {
startDate, startDate,
endDate, endDate,
status: inferSprintStatusForDateRange(startDate, endDate), status: inferSprintStatusForDateRange(startDate, endDate),
projectId: selectedProjectId,
} }
addSprint(sprint) addSprint(sprint)

View File

@ -3,7 +3,6 @@ export interface SprintLike {
startDate: string; startDate: string;
endDate: string; endDate: string;
status: "planning" | "active" | "completed"; status: "planning" | "active" | "completed";
projectId?: string;
} }
function parseSprintStart(value: string): Date { function parseSprintStart(value: string): Date {
@ -34,16 +33,12 @@ export function findCurrentSprint<T extends SprintLike>(
sprints: T[], sprints: T[],
options?: { options?: {
now?: Date now?: Date
projectId?: string
includeCompletedFallback?: boolean includeCompletedFallback?: boolean
} }
): T | null { ): T | null {
const now = options?.now ?? new Date() const now = options?.now ?? new Date()
const includeCompletedFallback = options?.includeCompletedFallback ?? false const includeCompletedFallback = options?.includeCompletedFallback ?? false
const projectId = options?.projectId const inProgress = sprints.filter((s) => isSprintInProgress(s.startDate, s.endDate, now))
const scoped = projectId ? sprints.filter((s) => s.projectId === projectId) : sprints
const inProgress = scoped.filter((s) => isSprintInProgress(s.startDate, s.endDate, now))
return ( return (
inProgress.find((s) => s.status === "active") ?? inProgress.find((s) => s.status === "active") ??

View File

@ -132,7 +132,6 @@ export interface Database {
start_date: string; start_date: string;
end_date: string; end_date: string;
status: 'planning' | 'active' | 'completed'; status: 'planning' | 'active' | 'completed';
project_id: string;
created_at: string; created_at: string;
}; };
Insert: { Insert: {
@ -142,7 +141,6 @@ export interface Database {
start_date: string; start_date: string;
end_date: string; end_date: string;
status: 'planning' | 'active' | 'completed'; status: 'planning' | 'active' | 'completed';
project_id: string;
created_at?: string; created_at?: string;
}; };
Update: { Update: {
@ -152,7 +150,6 @@ export interface Database {
start_date?: string; start_date?: string;
end_date?: string; end_date?: string;
status?: 'planning' | 'active' | 'completed'; status?: 'planning' | 'active' | 'completed';
project_id?: string;
created_at?: string; created_at?: string;
}; };
}; };

View File

@ -14,7 +14,6 @@ export interface Sprint {
startDate: string startDate: string
endDate: string endDate: string
status: SprintStatus status: SprintStatus
projectId: string
createdAt: string createdAt: string
} }

View File

@ -14,18 +14,17 @@ DECLARE
next_sprint_id UUID; next_sprint_id UUID;
BEGIN BEGIN
FOR ended_sprint IN FOR ended_sprint IN
SELECT id, project_id SELECT id
FROM public.sprints FROM public.sprints
WHERE status <> 'completed' WHERE status <> 'completed'
AND end_date < CURRENT_DATE AND end_date < CURRENT_DATE
ORDER BY end_date ASC, start_date ASC ORDER BY end_date ASC, start_date ASC
LOOP LOOP
-- Pick the next non-completed sprint in the same project. -- Pick the next non-completed sprint globally.
SELECT s.id SELECT s.id
INTO next_sprint_id INTO next_sprint_id
FROM public.sprints s FROM public.sprints s
WHERE s.project_id = ended_sprint.project_id WHERE s.status <> 'completed'
AND s.status <> 'completed'
AND s.id <> ended_sprint.id AND s.id <> ended_sprint.id
ORDER BY s.start_date ASC ORDER BY s.start_date ASC
LIMIT 1; LIMIT 1;
@ -66,4 +65,3 @@ BEGIN
); );
END; END;
$$; $$;

View File

@ -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;

View File

@ -123,12 +123,10 @@ CREATE TABLE IF NOT EXISTS sprints (
start_date DATE NOT NULL, start_date DATE NOT NULL,
end_date DATE NOT NULL, end_date DATE NOT NULL,
status TEXT NOT NULL CHECK (status IN ('planning', 'active', 'completed')), 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() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
-- Create indexes -- 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_legacy_id ON sprints(legacy_id);
CREATE INDEX IF NOT EXISTS idx_sprints_dates ON sprints(start_date, end_date); CREATE INDEX IF NOT EXISTS idx_sprints_dates ON sprints(start_date, end_date);
@ -261,18 +259,17 @@ DECLARE
next_sprint_id UUID; next_sprint_id UUID;
BEGIN BEGIN
FOR ended_sprint IN FOR ended_sprint IN
SELECT id, project_id SELECT id
FROM public.sprints FROM public.sprints
WHERE status <> 'completed' WHERE status <> 'completed'
AND end_date < CURRENT_DATE AND end_date < CURRENT_DATE
ORDER BY end_date ASC, start_date ASC ORDER BY end_date ASC, start_date ASC
LOOP LOOP
-- Pick the next non-completed sprint in the same project. -- Pick the next non-completed sprint globally.
SELECT s.id SELECT s.id
INTO next_sprint_id INTO next_sprint_id
FROM public.sprints s FROM public.sprints s
WHERE s.project_id = ended_sprint.project_id WHERE s.status <> 'completed'
AND s.status <> 'completed'
AND s.id <> ended_sprint.id AND s.id <> ended_sprint.id
ORDER BY s.start_date ASC ORDER BY s.start_date ASC
LIMIT 1; LIMIT 1;