Signed-off-by: Max <ai-agent@topdoglabs.com>
This commit is contained in:
parent
c571130029
commit
95fe894ed4
13
README.md
13
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": "<uuid>" }`
|
||||
- `{ "name": "Sprint 1", "startDate": "2026-02-16", "endDate": "2026-02-22", "status": "active" }`
|
||||
- `PATCH /api/sprints`
|
||||
- `{ "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'`
|
||||
|
||||
@ -51,7 +51,7 @@ Authenticate once:
|
||||
```bash
|
||||
./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 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 --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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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"),
|
||||
]);
|
||||
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
545
src/app/sprints/page.tsx
Normal file
545
src/app/sprints/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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") ??
|
||||
|
||||
@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@ -14,7 +14,6 @@ export interface Sprint {
|
||||
startDate: string
|
||||
endDate: string
|
||||
status: SprintStatus
|
||||
projectId: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
$$;
|
||||
|
||||
|
||||
49
supabase/remove_sprint_project_id.sql
Normal file
49
supabase/remove_sprint_project_id.sql
Normal 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;
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user