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 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'`
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -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) => (
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
@ -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
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) => {
|
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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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") ??
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
|||||||
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,
|
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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user