Signed-off-by: Max <ai-agent@topdoglabs.com>
This commit is contained in:
parent
95fe894ed4
commit
5dcac7c9e6
@ -100,8 +100,8 @@ Bulk JSON format:
|
|||||||
```bash
|
```bash
|
||||||
./sprint.sh list [--status <planning|active|completed>] [--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>" [--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]
|
||||||
./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 update <sprint-id-or-name> [--name "..."] [--goal "..."] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD]
|
||||||
./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>
|
||||||
```
|
```
|
||||||
@ -137,7 +137,8 @@ The task/project/sprint scripts support name-to-ID resolution against API data:
|
|||||||
- assignee names/emails -> `assigneeId`
|
- assignee names/emails -> `assigneeId`
|
||||||
- sprint value `current` -> `/api/sprints/current`
|
- 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.
|
Sprint status is derived from `start_date`/`end_date`; no manual sprint status writes are required.
|
||||||
|
`./sprint.sh list --active` uses `/api/sprints?inProgress=true` and returns all sprints whose date window includes the API server's current date at execution time.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
@ -401,8 +401,7 @@ cmd_sprint_create() {
|
|||||||
name: $name,
|
name: $name,
|
||||||
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)
|
||||||
status: "planning"
|
|
||||||
}')
|
}')
|
||||||
api_call POST "/sprints" "$data"
|
api_call POST "/sprints" "$data"
|
||||||
}
|
}
|
||||||
@ -414,7 +413,12 @@ 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"
|
echo "Fields: name, goal, startDate, endDate"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$field" = "status" ]; then
|
||||||
|
log_error "Sprint status is date-derived. Update startDate/endDate instead."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@ -591,8 +595,7 @@ SPRINT COMMANDS:
|
|||||||
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
|
|
||||||
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
|
||||||
# Sprint Auto-Status and Rollover Script
|
# Sprint Rollover Script
|
||||||
# Updates sprint statuses based on current date and rolls over incomplete tasks
|
# Sprint state is date-derived; this script only handles task rollover for ended sprints.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@ -39,25 +39,6 @@ get_sprint_target_status() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Update sprint status
|
|
||||||
update_sprint_status() {
|
|
||||||
local sprint_id="$1"
|
|
||||||
local new_status="$2"
|
|
||||||
|
|
||||||
log "Updating sprint $sprint_id to status: $new_status"
|
|
||||||
|
|
||||||
local payload
|
|
||||||
payload=$(jq -n --arg id "$sprint_id" --arg status "$new_status" '{id: $id, status: $status}')
|
|
||||||
|
|
||||||
if api_call PATCH "/sprints" "$payload" > /dev/null 2>&1; then
|
|
||||||
log "✓ Successfully updated sprint $sprint_id to $new_status"
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
log "✗ Failed to update sprint $sprint_id"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get incomplete tasks for a sprint (status not in done, canceled, closed)
|
# Get incomplete tasks for a sprint (status not in done, canceled, closed)
|
||||||
get_incomplete_tasks() {
|
get_incomplete_tasks() {
|
||||||
local sprint_id="$1"
|
local sprint_id="$1"
|
||||||
@ -134,7 +115,7 @@ move_task_to_sprint() {
|
|||||||
|
|
||||||
# Main function to process sprint updates
|
# Main function to process sprint updates
|
||||||
process_sprint_updates() {
|
process_sprint_updates() {
|
||||||
log "=== Starting Sprint Auto-Status Update ==="
|
log "=== Starting Sprint Rollover Run ==="
|
||||||
log "Today: $(get_today)"
|
log "Today: $(get_today)"
|
||||||
|
|
||||||
# Get all sprints
|
# Get all sprints
|
||||||
@ -146,7 +127,6 @@ process_sprint_updates() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local updated_count=0
|
|
||||||
local rollover_count=0
|
local rollover_count=0
|
||||||
|
|
||||||
# Process each sprint
|
# Process each sprint
|
||||||
@ -154,13 +134,11 @@ process_sprint_updates() {
|
|||||||
[[ -z "$sprint" ]] && continue
|
[[ -z "$sprint" ]] && continue
|
||||||
|
|
||||||
local sprint_id
|
local sprint_id
|
||||||
local current_status
|
|
||||||
local start_date
|
local start_date
|
||||||
local end_date
|
local end_date
|
||||||
local sprint_name
|
local sprint_name
|
||||||
|
|
||||||
sprint_id=$(echo "$sprint" | jq -r '.id // empty')
|
sprint_id=$(echo "$sprint" | jq -r '.id // empty')
|
||||||
current_status=$(echo "$sprint" | jq -r '.status // empty')
|
|
||||||
start_date=$(echo "$sprint" | jq -r '.start_date // .startDate // empty')
|
start_date=$(echo "$sprint" | jq -r '.start_date // .startDate // empty')
|
||||||
end_date=$(echo "$sprint" | jq -r '.end_date // .endDate // empty')
|
end_date=$(echo "$sprint" | jq -r '.end_date // .endDate // empty')
|
||||||
sprint_name=$(echo "$sprint" | jq -r '.name // "Unnamed"')
|
sprint_name=$(echo "$sprint" | jq -r '.name // "Unnamed"')
|
||||||
@ -171,16 +149,11 @@ process_sprint_updates() {
|
|||||||
local target_status
|
local target_status
|
||||||
target_status=$(get_sprint_target_status "$start_date" "$end_date")
|
target_status=$(get_sprint_target_status "$start_date" "$end_date")
|
||||||
|
|
||||||
log "Sprint: $sprint_name (ID: $sprint_id) - Current: $current_status, Target: $target_status"
|
log "Sprint: $sprint_name (ID: $sprint_id) - Derived status: $target_status"
|
||||||
|
|
||||||
# Update if different
|
# Rollover applies only to completed (ended) sprints.
|
||||||
if [[ "$current_status" != "$target_status" ]]; then
|
|
||||||
if update_sprint_status "$sprint_id" "$target_status"; then
|
|
||||||
((updated_count++))
|
|
||||||
|
|
||||||
# If sprint was just completed, roll over incomplete tasks
|
|
||||||
if [[ "$target_status" == "completed" ]]; then
|
if [[ "$target_status" == "completed" ]]; then
|
||||||
log "Sprint $sprint_name completed - checking for tasks to roll over"
|
log "Sprint $sprint_name has ended - checking for tasks to roll over"
|
||||||
|
|
||||||
# Get the new current sprint
|
# Get the new current sprint
|
||||||
local current_sprint
|
local current_sprint
|
||||||
@ -214,15 +187,10 @@ process_sprint_updates() {
|
|||||||
log "Warning: No current active sprint found for rollover"
|
log "Warning: No current active sprint found for rollover"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "Status is correct, no update needed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
done < <(echo "$sprints" | jq -c '.[]' 2>/dev/null)
|
done < <(echo "$sprints" | jq -c '.[]' 2>/dev/null)
|
||||||
|
|
||||||
log "=== Sprint Update Complete ==="
|
log "=== Sprint Update Complete ==="
|
||||||
log "Updated: $updated_count sprints"
|
|
||||||
log "Rolled over: $rollover_count tasks"
|
log "Rolled over: $rollover_count tasks"
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
@ -231,13 +199,13 @@ process_sprint_updates() {
|
|||||||
# Show help
|
# Show help
|
||||||
show_help() {
|
show_help() {
|
||||||
cat << 'HELP'
|
cat << 'HELP'
|
||||||
Sprint Auto-Status and Rollover Script
|
Sprint Rollover Script
|
||||||
|
|
||||||
USAGE:
|
USAGE:
|
||||||
./sprint-auto-status.sh [command]
|
./sprint-auto-status.sh [command]
|
||||||
|
|
||||||
COMMANDS:
|
COMMANDS:
|
||||||
run Run the auto-status update and rollover process
|
run Run the rollover process for ended sprints
|
||||||
dry-run Show what would happen without making changes
|
dry-run Show what would happen without making changes
|
||||||
cleanup Clean up old log files
|
cleanup Clean up old log files
|
||||||
help Show this help message
|
help Show this help message
|
||||||
@ -260,8 +228,8 @@ case "${1:-}" in
|
|||||||
dry-run)
|
dry-run)
|
||||||
log "=== DRY RUN MODE - No changes will be made ==="
|
log "=== DRY RUN MODE - No changes will be made ==="
|
||||||
# In dry-run mode, we'd print what would happen
|
# In dry-run mode, we'd print what would happen
|
||||||
log "Would check all sprints and update statuses based on $(get_today)"
|
log "Would check all sprints and derive state from dates on $(get_today)"
|
||||||
log "Would roll over incomplete tasks from completed sprints"
|
log "Would roll over incomplete tasks from ended sprints"
|
||||||
;;
|
;;
|
||||||
cleanup)
|
cleanup)
|
||||||
rm -f /tmp/sprint-auto-status.log
|
rm -f /tmp/sprint-auto-status.log
|
||||||
|
|||||||
@ -48,7 +48,6 @@ CREATE OPTIONS:
|
|||||||
--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
|
||||||
--status [planning|active|completed] Status (default: planning)
|
|
||||||
|
|
||||||
LIST OPTIONS:
|
LIST OPTIONS:
|
||||||
--status <status> Filter by status
|
--status <status> Filter by status
|
||||||
@ -89,7 +88,6 @@ cmd_create() {
|
|||||||
local goal=""
|
local goal=""
|
||||||
local start_date=""
|
local start_date=""
|
||||||
local end_date=""
|
local end_date=""
|
||||||
local status="planning"
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
@ -97,7 +95,6 @@ cmd_create() {
|
|||||||
--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 ;;
|
||||||
--status) status="${2:-}"; shift 2 ;;
|
|
||||||
*) shift ;;
|
*) shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@ -113,13 +110,11 @@ cmd_create() {
|
|||||||
--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" \
|
|
||||||
'{
|
'{
|
||||||
name: $name,
|
name: $name,
|
||||||
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)
|
||||||
status: $status
|
|
||||||
}')
|
}')
|
||||||
|
|
||||||
log_info "Creating sprint..."
|
log_info "Creating sprint..."
|
||||||
@ -146,10 +141,7 @@ cmd_list() {
|
|||||||
query_params+=("status=$(urlencode "$filter_status")")
|
query_params+=("status=$(urlencode "$filter_status")")
|
||||||
fi
|
fi
|
||||||
if [[ "$active_only" == true ]]; then
|
if [[ "$active_only" == true ]]; then
|
||||||
local today
|
|
||||||
today=$(date +%Y-%m-%d)
|
|
||||||
query_params+=("inProgress=true")
|
query_params+=("inProgress=true")
|
||||||
query_params+=("onDate=$(urlencode "$today")")
|
|
||||||
fi
|
fi
|
||||||
if [[ ${#query_params[@]} -gt 0 ]]; then
|
if [[ ${#query_params[@]} -gt 0 ]]; then
|
||||||
endpoint+="?$(IFS='&'; echo "${query_params[*]}")"
|
endpoint+="?$(IFS='&'; echo "${query_params[*]}")"
|
||||||
@ -212,7 +204,6 @@ cmd_update() {
|
|||||||
--goal) updates=$(echo "$updates" | jq --arg v "${2:-}" '. + {goal: $v}'); shift 2 ;;
|
--goal) updates=$(echo "$updates" | jq --arg v "${2:-}" '. + {goal: $v}'); shift 2 ;;
|
||||||
--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 ;;
|
|
||||||
*) shift ;;
|
*) shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|||||||
@ -106,7 +106,7 @@ 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?inProgress=true&onDate="*)
|
"GET http://localhost:3000/api/sprints?inProgress=true")
|
||||||
respond '{"sprints":[{"id":"s1","name":"Sprint 1","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")
|
||||||
@ -203,7 +203,7 @@ BULK_EOF
|
|||||||
"$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" --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" --goal "Updated Goal" >/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
|
||||||
|
|
||||||
@ -225,7 +225,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" "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 goal "Updated Goal" >/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
|
||||||
|
|
||||||
@ -251,7 +251,7 @@ 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?inProgress=true&onDate="
|
assert_log_contains "GET http://localhost:3000/api/sprints?inProgress=true"
|
||||||
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 "POST http://localhost:3000/api/sprints"
|
assert_log_contains "POST http://localhost:3000/api/sprints"
|
||||||
|
|||||||
@ -10,38 +10,30 @@ test("isSprintInProgress uses inclusive boundaries", () => {
|
|||||||
assert.equal(isSprintInProgress("2026-02-25", "2026-02-26", NOW), false)
|
assert.equal(isSprintInProgress("2026-02-25", "2026-02-26", NOW), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("findCurrentSprint prefers active sprint in range", () => {
|
test("findCurrentSprint returns in-range sprint regardless of status", () => {
|
||||||
const sprint = findCurrentSprint(
|
const sprint = findCurrentSprint(
|
||||||
[
|
[
|
||||||
{ id: "planning", status: "planning", startDate: "2026-02-20", endDate: "2026-02-28" },
|
{ id: "done", status: "completed", startDate: "2026-02-20", endDate: "2026-02-28" },
|
||||||
{ id: "active", status: "active", startDate: "2026-02-22", endDate: "2026-02-26" },
|
|
||||||
],
|
],
|
||||||
{ now: NOW }
|
{ now: NOW }
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.equal(sprint?.id, "active")
|
assert.equal(sprint?.id, "done")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("findCurrentSprint falls back to non-completed in range", () => {
|
test("findCurrentSprint prefers the most recently started overlapping sprint", () => {
|
||||||
const sprint = findCurrentSprint(
|
const sprint = findCurrentSprint(
|
||||||
[{ id: "planning", status: "planning", startDate: "2026-02-20", endDate: "2026-02-28" }],
|
[
|
||||||
|
{ id: "earlier", status: "planning", startDate: "2026-02-18", endDate: "2026-02-27" },
|
||||||
|
{ id: "latest", status: "planning", startDate: "2026-02-22", endDate: "2026-02-26" },
|
||||||
|
],
|
||||||
{ now: NOW }
|
{ now: NOW }
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.equal(sprint?.id, "planning")
|
assert.equal(sprint?.id, "latest")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("findCurrentSprint returns null for completed-only unless fallback enabled", () => {
|
test("findCurrentSprint keeps deterministic order when date windows are identical", () => {
|
||||||
const sprints = [{ id: "done", status: "completed", startDate: "2026-02-20", endDate: "2026-02-28" }] as const
|
|
||||||
|
|
||||||
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 is global across all sprints", () => {
|
|
||||||
const sprint = findCurrentSprint(
|
const sprint = findCurrentSprint(
|
||||||
[
|
[
|
||||||
{ id: "planning", status: "planning", startDate: "2026-02-20", endDate: "2026-02-28" },
|
{ id: "planning", status: "planning", startDate: "2026-02-20", endDate: "2026-02-28" },
|
||||||
@ -50,7 +42,7 @@ test("findCurrentSprint is global across all sprints", () => {
|
|||||||
{ now: NOW }
|
{ now: NOW }
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.equal(sprint?.id, "active")
|
assert.equal(sprint?.id, "planning")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("findCurrentSprint returns null when no sprint is in range", () => {
|
test("findCurrentSprint returns null when no sprint is in range", () => {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getServiceSupabase } from "@/lib/supabase/client";
|
import { getServiceSupabase } from "@/lib/supabase/client";
|
||||||
import { getAuthenticatedUser } from "@/lib/server/auth";
|
import { getAuthenticatedUser } from "@/lib/server/auth";
|
||||||
|
import { inferSprintStatusForDateRange } from "@/lib/server/sprintState";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@ -31,7 +32,17 @@ export async function GET(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ sprint: data });
|
const startDate = typeof data.start_date === "string" && data.start_date.trim().length > 0 ? data.start_date : null;
|
||||||
|
const endDate = typeof data.end_date === "string" && data.end_date.trim().length > 0 ? data.end_date : null;
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
throw new Error("Sprint row is missing start_date or end_date");
|
||||||
|
}
|
||||||
|
const sprint = {
|
||||||
|
...data,
|
||||||
|
status: inferSprintStatusForDateRange(startDate, endDate),
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({ sprint });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(">>> API GET /sprints/[id] error:", error);
|
console.error(">>> API GET /sprints/[id] error:", error);
|
||||||
return NextResponse.json({ error: "Failed to fetch sprint" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to fetch sprint" }, { status: 500 });
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { getServiceSupabase } from "@/lib/supabase/client"
|
import { getServiceSupabase } from "@/lib/supabase/client"
|
||||||
import { getAuthenticatedUser } from "@/lib/server/auth"
|
import { getAuthenticatedUser } from "@/lib/server/auth"
|
||||||
|
import { addDays, inferSprintStatusForDateRange, toLocalDateOnly } from "@/lib/server/sprintState"
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
@ -19,16 +20,28 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const supabase = getServiceSupabase()
|
const supabase = getServiceSupabase()
|
||||||
|
const closedEndDate = toLocalDateOnly(addDays(new Date(), -1))
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("sprints")
|
.from("sprints")
|
||||||
.update({ status: "completed" })
|
.update({ end_date: closedEndDate })
|
||||||
.eq("id", id)
|
.eq("id", id)
|
||||||
.select("*")
|
.select("*")
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
|
|
||||||
return NextResponse.json({ success: true, sprint: data })
|
const startDate = typeof data.start_date === "string" && data.start_date.trim().length > 0 ? data.start_date : null
|
||||||
|
const endDate = typeof data.end_date === "string" && data.end_date.trim().length > 0 ? data.end_date : null
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
throw new Error("Sprint row is missing start_date or end_date")
|
||||||
|
}
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
sprint: {
|
||||||
|
...data,
|
||||||
|
status: inferSprintStatusForDateRange(startDate, endDate),
|
||||||
|
},
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(">>> API POST /sprints/close error:", error)
|
console.error(">>> API POST /sprints/close error:", error)
|
||||||
return NextResponse.json({ error: "Failed to close sprint" }, { status: 500 })
|
return NextResponse.json({ error: "Failed to close sprint" }, { status: 500 })
|
||||||
|
|||||||
@ -2,23 +2,21 @@ import { NextResponse } from "next/server"
|
|||||||
import { getServiceSupabase } from "@/lib/supabase/client"
|
import { getServiceSupabase } from "@/lib/supabase/client"
|
||||||
import { getAuthenticatedUser } from "@/lib/server/auth"
|
import { getAuthenticatedUser } from "@/lib/server/auth"
|
||||||
import { findCurrentSprint } from "@/lib/server/sprintSelection"
|
import { findCurrentSprint } from "@/lib/server/sprintSelection"
|
||||||
|
import { inferSprintStatusForDateRange } from "@/lib/server/sprintState"
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const user = await getAuthenticatedUser()
|
const user = await getAuthenticatedUser()
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
|
||||||
const includeCompletedFallback = searchParams.get("includeCompletedFallback") === "true"
|
|
||||||
|
|
||||||
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,created_at")
|
.select("id,name,goal,start_date,end_date,created_at")
|
||||||
.order("start_date", { ascending: true })
|
.order("start_date", { ascending: true })
|
||||||
|
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
@ -29,11 +27,11 @@ export async function GET(request: Request) {
|
|||||||
goal: row.goal,
|
goal: row.goal,
|
||||||
startDate: row.start_date,
|
startDate: row.start_date,
|
||||||
endDate: row.end_date,
|
endDate: row.end_date,
|
||||||
status: row.status,
|
status: inferSprintStatusForDateRange(row.start_date, row.end_date),
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const sprint = findCurrentSprint(mapped, { includeCompletedFallback })
|
const sprint = findCurrentSprint(mapped)
|
||||||
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)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getServiceSupabase } from "@/lib/supabase/client";
|
import { getServiceSupabase } from "@/lib/supabase/client";
|
||||||
import { getAuthenticatedUser } from "@/lib/server/auth";
|
import { getAuthenticatedUser } from "@/lib/server/auth";
|
||||||
|
import { inferSprintStatusForDateRange, toLocalDateOnly } from "@/lib/server/sprintState";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@ -55,11 +56,26 @@ function requireUuid(value: unknown, field: string): string {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireSprintStatus(value: unknown, field: string): SprintStatus {
|
function requireRowString(value: unknown, field: string): string {
|
||||||
if (typeof value !== "string" || !SPRINT_STATUSES.includes(value as SprintStatus)) {
|
if (typeof value !== "string" || value.trim().length === 0) {
|
||||||
throw new HttpError(400, `${field} must be one of: ${SPRINT_STATUSES.join(", ")}`, { field, value });
|
throw new HttpError(500, `${field} is missing in database row`, { field, value });
|
||||||
}
|
}
|
||||||
return value as SprintStatus;
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeSprintRow(row: Record<string, unknown>, now: Date): Record<string, unknown> {
|
||||||
|
const startDate = requireRowString(row.start_date, "sprints.start_date");
|
||||||
|
const endDate = requireRowString(row.end_date, "sprints.end_date");
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
goal: row.goal ?? null,
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
status: inferSprintStatusForDateRange(startDate, endDate, now),
|
||||||
|
created_at: row.created_at,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET - fetch all sprints (optionally filtered by status)
|
// GET - fetch all sprints (optionally filtered by status)
|
||||||
@ -82,6 +98,7 @@ export async function GET(request: Request) {
|
|||||||
if (onDateInput && !onDate) {
|
if (onDateInput && !onDate) {
|
||||||
throw new HttpError(400, "onDate must be YYYY-MM-DD or ISO date-time", { onDate: onDateInput });
|
throw new HttpError(400, "onDate must be YYYY-MM-DD or ISO date-time", { onDate: onDateInput });
|
||||||
}
|
}
|
||||||
|
const filterDate = onDate || toLocalDateOnly();
|
||||||
|
|
||||||
const supabase = getServiceSupabase();
|
const supabase = getServiceSupabase();
|
||||||
let query = supabase
|
let query = supabase
|
||||||
@ -89,20 +106,27 @@ export async function GET(request: Request) {
|
|||||||
.select("*")
|
.select("*")
|
||||||
.order("start_date", { ascending: true });
|
.order("start_date", { ascending: true });
|
||||||
|
|
||||||
// Filter by status if provided
|
// Filter by derived status if provided (status is inferred from date range).
|
||||||
if (status) {
|
if (status) {
|
||||||
query = query.eq("status", status);
|
if (status === "planning") {
|
||||||
|
query = query.gt("start_date", filterDate);
|
||||||
|
} else if (status === "active") {
|
||||||
|
query = query.lte("start_date", filterDate).gte("end_date", filterDate);
|
||||||
|
} else {
|
||||||
|
query = query.lt("end_date", filterDate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (inProgress) {
|
if (inProgress) {
|
||||||
const resolvedDate = onDate || new Date().toISOString().slice(0, 10);
|
query = query.lte("start_date", filterDate).gte("end_date", filterDate);
|
||||||
query = query.lte("start_date", resolvedDate).gte("end_date", resolvedDate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: sprints, error } = await query;
|
const { data: sprints, error } = await query;
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
return NextResponse.json({ sprints: sprints || [] });
|
const now = new Date();
|
||||||
|
const serialized = (sprints || []).map((row) => serializeSprintRow(row as unknown as Record<string, unknown>, now));
|
||||||
|
return NextResponse.json({ sprints: serialized });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(">>> API GET /sprints error:", error);
|
console.error(">>> API GET /sprints error:", error);
|
||||||
if (error instanceof HttpError) {
|
if (error instanceof HttpError) {
|
||||||
@ -121,7 +145,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, goal, startDate, endDate, status } = body;
|
const { name, goal, startDate, endDate } = body;
|
||||||
|
|
||||||
const supabase = getServiceSupabase();
|
const supabase = getServiceSupabase();
|
||||||
const resolvedName = requireNonEmptyString(name, "name");
|
const resolvedName = requireNonEmptyString(name, "name");
|
||||||
@ -133,7 +157,6 @@ export async function POST(request: Request) {
|
|||||||
if (!normalizedEndDate) {
|
if (!normalizedEndDate) {
|
||||||
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 now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
@ -143,7 +166,6 @@ export async function POST(request: Request) {
|
|||||||
goal: goal || null,
|
goal: goal || null,
|
||||||
start_date: normalizedStartDate,
|
start_date: normalizedStartDate,
|
||||||
end_date: normalizedEndDate,
|
end_date: normalizedEndDate,
|
||||||
status: resolvedStatus,
|
|
||||||
created_at: now,
|
created_at: now,
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
@ -151,7 +173,10 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
return NextResponse.json({ success: true, sprint: data });
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
sprint: serializeSprintRow(data as unknown as Record<string, unknown>, new Date()),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(">>> API POST /sprints error:", error);
|
console.error(">>> API POST /sprints error:", error);
|
||||||
if (error instanceof HttpError) {
|
if (error instanceof HttpError) {
|
||||||
@ -193,7 +218,25 @@ export async function PATCH(request: Request) {
|
|||||||
}
|
}
|
||||||
dbUpdates.end_date = normalizedEndDate;
|
dbUpdates.end_date = normalizedEndDate;
|
||||||
}
|
}
|
||||||
if (updates.status !== undefined) dbUpdates.status = requireSprintStatus(updates.status, "status");
|
// Sprint status is derived from dates; ignore manual status updates.
|
||||||
|
if (updates.status !== undefined) {
|
||||||
|
console.warn(">>> API PATCH /sprints: ignoring updates.status because status is date-derived");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(dbUpdates).length === 0) {
|
||||||
|
const { data: existing, error: existingError } = await supabase
|
||||||
|
.from("sprints")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", sprintId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (existingError) throw existingError;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
sprint: serializeSprintRow(existing as unknown as Record<string, unknown>, new Date()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("sprints")
|
.from("sprints")
|
||||||
@ -204,7 +247,10 @@ export async function PATCH(request: Request) {
|
|||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
return NextResponse.json({ success: true, sprint: data });
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
sprint: serializeSprintRow(data as unknown as Record<string, unknown>, new Date()),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(">>> API PATCH /sprints error:", error);
|
console.error(">>> API PATCH /sprints error:", error);
|
||||||
if (error instanceof HttpError) {
|
if (error instanceof HttpError) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { getServiceSupabase } from "@/lib/supabase/client";
|
import { getServiceSupabase } from "@/lib/supabase/client";
|
||||||
import { getAuthenticatedUser } from "@/lib/server/auth";
|
import { getAuthenticatedUser } from "@/lib/server/auth";
|
||||||
import { findCurrentSprint } from "@/lib/server/sprintSelection";
|
import { findCurrentSprint } from "@/lib/server/sprintSelection";
|
||||||
|
import { inferSprintStatusForDateRange } from "@/lib/server/sprintState";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@ -60,7 +61,6 @@ interface UserProfile {
|
|||||||
const TASK_TYPES: Task["type"][] = ["idea", "task", "bug", "research", "plan"];
|
const TASK_TYPES: Task["type"][] = ["idea", "task", "bug", "research", "plan"];
|
||||||
const TASK_STATUSES: Task["status"][] = ["open", "todo", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"];
|
const TASK_STATUSES: Task["status"][] = ["open", "todo", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"];
|
||||||
const TASK_PRIORITIES: Task["priority"][] = ["low", "medium", "high", "urgent"];
|
const TASK_PRIORITIES: Task["priority"][] = ["low", "medium", "high", "urgent"];
|
||||||
const SPRINT_STATUSES: Sprint["status"][] = ["planning", "active", "completed"];
|
|
||||||
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;
|
||||||
|
|
||||||
// Field sets are split so board loads can avoid heavy attachment payloads.
|
// Field sets are split so board loads can avoid heavy attachment payloads.
|
||||||
@ -203,13 +203,15 @@ function mapProjectRow(row: Record<string, unknown>): Project {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapSprintRow(row: Record<string, unknown>): Sprint {
|
function mapSprintRow(row: Record<string, unknown>): Sprint {
|
||||||
|
const startDate = requireNonEmptyString(row.start_date, "sprints.start_date", 500);
|
||||||
|
const endDate = requireNonEmptyString(row.end_date, "sprints.end_date", 500);
|
||||||
return {
|
return {
|
||||||
id: requireNonEmptyString(row.id, "sprints.id", 500),
|
id: requireNonEmptyString(row.id, "sprints.id", 500),
|
||||||
name: requireNonEmptyString(row.name, "sprints.name", 500),
|
name: requireNonEmptyString(row.name, "sprints.name", 500),
|
||||||
goal: toNonEmptyString(row.goal),
|
goal: toNonEmptyString(row.goal),
|
||||||
startDate: requireNonEmptyString(row.start_date, "sprints.start_date", 500),
|
startDate,
|
||||||
endDate: requireNonEmptyString(row.end_date, "sprints.end_date", 500),
|
endDate,
|
||||||
status: requireEnum(row.status, SPRINT_STATUSES, "sprints.status", 500),
|
status: inferSprintStatusForDateRange(startDate, endDate),
|
||||||
createdAt: requireNonEmptyString(row.created_at, "sprints.created_at", 500),
|
createdAt: requireNonEmptyString(row.created_at, "sprints.created_at", 500),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -304,7 +306,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, created_at").order("start_date", { ascending: true }),
|
supabase.from("sprints").select("id, name, goal, start_date, end_date, 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"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -379,7 +379,6 @@ export default function Home() {
|
|||||||
setCurrentUser,
|
setCurrentUser,
|
||||||
syncFromServer,
|
syncFromServer,
|
||||||
isLoading,
|
isLoading,
|
||||||
updateSprint,
|
|
||||||
} = useTaskStore()
|
} = useTaskStore()
|
||||||
|
|
||||||
const [newTaskOpen, setNewTaskOpen] = useState(false)
|
const [newTaskOpen, setNewTaskOpen] = useState(false)
|
||||||
@ -634,13 +633,11 @@ export default function Home() {
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
const sprintsInProgress = sprints.filter((s) => isSprintInProgress(s.startDate, s.endDate, now))
|
const sprintsInProgress = sprints.filter((s) => isSprintInProgress(s.startDate, s.endDate, now))
|
||||||
|
|
||||||
// First: prefer active sprints that are in date range
|
// Current sprint is selected by date window only.
|
||||||
// Second: any sprint in date range (not completed)
|
|
||||||
// Third: any sprint in date range (even if completed, for edge cases)
|
|
||||||
const currentSprint =
|
const currentSprint =
|
||||||
sprintsInProgress.find((s) => s.status === "active") ??
|
sprintsInProgress
|
||||||
sprintsInProgress.find((s) => s.status !== "completed") ??
|
.slice()
|
||||||
sprintsInProgress[0] ??
|
.sort((a, b) => parseSprintStart(b.startDate).getTime() - parseSprintStart(a.startDate).getTime())[0] ??
|
||||||
null
|
null
|
||||||
|
|
||||||
// Filter tasks to only show current sprint tasks in Kanban (from ALL projects)
|
// Filter tasks to only show current sprint tasks in Kanban (from ALL projects)
|
||||||
@ -666,17 +663,13 @@ export default function Home() {
|
|||||||
if (!authReady || !hasLoadedAllTasks || sprints.length === 0) return
|
if (!authReady || !hasLoadedAllTasks || sprints.length === 0) return
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const endedSprints = sprints.filter((s) => {
|
const endedSprints = sprints.filter((s) => parseSprintEnd(s.endDate) < now)
|
||||||
if (s.status === "completed") return false
|
|
||||||
const sprintEnd = parseSprintEnd(s.endDate)
|
|
||||||
return sprintEnd < now
|
|
||||||
})
|
|
||||||
|
|
||||||
if (endedSprints.length === 0) return
|
if (endedSprints.length === 0) return
|
||||||
|
|
||||||
// Find next sprint (earliest start date that's in the future or active)
|
// Find next available sprint (earliest start date that has not ended).
|
||||||
const nextSprint = sprints
|
const nextSprint = sprints
|
||||||
.filter((s) => s.status !== "completed" && !endedSprints.find((e) => e.id === s.id))
|
.filter((s) => parseSprintEnd(s.endDate) >= now && !endedSprints.find((e) => e.id === s.id))
|
||||||
.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime())[0]
|
.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime())[0]
|
||||||
|
|
||||||
if (!nextSprint) return
|
if (!nextSprint) return
|
||||||
@ -695,14 +688,9 @@ export default function Home() {
|
|||||||
updateTask(task.id, { sprintId: nextSprint.id })
|
updateTask(task.id, { sprintId: nextSprint.id })
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mark ended sprint as completed
|
|
||||||
updateSprint(endedSprint.id, { status: 'completed' })
|
|
||||||
} else {
|
|
||||||
// No incomplete tasks, just mark as completed
|
|
||||||
updateSprint(endedSprint.id, { status: 'completed' })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [authReady, hasLoadedAllTasks, sprints, tasks, updateTask, updateSprint])
|
}, [authReady, hasLoadedAllTasks, sprints, tasks, updateTask])
|
||||||
|
|
||||||
const activeKanbanTask = activeKanbanTaskId
|
const activeKanbanTask = activeKanbanTaskId
|
||||||
? sprintTasks.find((task) => task.id === activeKanbanTaskId)
|
? sprintTasks.find((task) => task.id === activeKanbanTaskId)
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { useTaskStore, SprintStatus } from "@/stores/useTaskStore"
|
import { useTaskStore, SprintStatus } from "@/stores/useTaskStore"
|
||||||
import { inferSprintStatusForDateRange, parseSprintEnd, parseSprintStart, toLocalDateInputValue } from "@/lib/utils"
|
import { parseSprintEnd, parseSprintStart, toLocalDateInputValue } from "@/lib/utils"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
type SprintDraft = {
|
type SprintDraft = {
|
||||||
@ -19,7 +19,6 @@ type SprintDraft = {
|
|||||||
goal: string
|
goal: string
|
||||||
startDate: string
|
startDate: string
|
||||||
endDate: string
|
endDate: string
|
||||||
status: SprintStatus
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<SprintStatus, string> = {
|
const STATUS_LABELS: Record<SprintStatus, string> = {
|
||||||
@ -51,7 +50,6 @@ function buildDefaultDraft(): SprintDraft {
|
|||||||
goal: "",
|
goal: "",
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
status: inferSprintStatusForDateRange(startDate, endDate),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -156,7 +154,6 @@ export default function SprintsPage() {
|
|||||||
goal: sprint.goal || "",
|
goal: sprint.goal || "",
|
||||||
startDate: sprint.startDate,
|
startDate: sprint.startDate,
|
||||||
endDate: sprint.endDate,
|
endDate: sprint.endDate,
|
||||||
status: sprint.status,
|
|
||||||
})
|
})
|
||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
}
|
}
|
||||||
@ -190,7 +187,6 @@ export default function SprintsPage() {
|
|||||||
goal: draft.goal.trim() || undefined,
|
goal: draft.goal.trim() || undefined,
|
||||||
startDate: draft.startDate,
|
startDate: draft.startDate,
|
||||||
endDate: draft.endDate,
|
endDate: draft.endDate,
|
||||||
status: draft.status,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editorMode === "edit") {
|
if (editorMode === "edit") {
|
||||||
@ -459,7 +455,6 @@ export default function SprintsPage() {
|
|||||||
setDraft((prev) => ({
|
setDraft((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
startDate,
|
startDate,
|
||||||
status: inferSprintStatusForDateRange(startDate, prev.endDate),
|
|
||||||
}))
|
}))
|
||||||
}}
|
}}
|
||||||
className="mt-1 bg-slate-800 border-slate-700 text-white"
|
className="mt-1 bg-slate-800 border-slate-700 text-white"
|
||||||
@ -475,7 +470,6 @@ export default function SprintsPage() {
|
|||||||
setDraft((prev) => ({
|
setDraft((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
endDate,
|
endDate,
|
||||||
status: inferSprintStatusForDateRange(prev.startDate, endDate),
|
|
||||||
}))
|
}))
|
||||||
}}
|
}}
|
||||||
className="mt-1 bg-slate-800 border-slate-700 text-white"
|
className="mt-1 bg-slate-800 border-slate-700 text-white"
|
||||||
@ -483,19 +477,6 @@ export default function SprintsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@ -26,7 +26,6 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Plus, GripVertical, ChevronDown, ChevronRight, Calendar } from "lucide-react"
|
import { Plus, GripVertical, ChevronDown, ChevronRight, Calendar } from "lucide-react"
|
||||||
import { format, isValid } from "date-fns"
|
import { format, isValid } from "date-fns"
|
||||||
import {
|
import {
|
||||||
inferSprintStatusForDateRange,
|
|
||||||
isSprintInProgress,
|
isSprintInProgress,
|
||||||
parseSprintEnd,
|
parseSprintEnd,
|
||||||
parseSprintStart,
|
parseSprintStart,
|
||||||
@ -319,9 +318,9 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
|
|||||||
? tasks.filter((t) => t.projectId === selectedProjectId)
|
? tasks.filter((t) => t.projectId === selectedProjectId)
|
||||||
: tasks
|
: tasks
|
||||||
|
|
||||||
const currentSprint =
|
const currentSprint = projectSprints
|
||||||
projectSprints.find((s) => s.status === "active" && isSprintInProgress(s.startDate, s.endDate, now)) ??
|
.filter((s) => isSprintInProgress(s.startDate, s.endDate, now))
|
||||||
projectSprints.find((s) => s.status !== "completed" && isSprintInProgress(s.startDate, s.endDate, now))
|
.sort((a, b) => parseSprintStart(b.startDate).getTime() - parseSprintStart(a.startDate).getTime())[0]
|
||||||
|
|
||||||
// Get other sprints (not current)
|
// Get other sprints (not current)
|
||||||
const otherSprints = projectSprints.filter((s) => s.id !== currentSprint?.id)
|
const otherSprints = projectSprints.filter((s) => s.id !== currentSprint?.id)
|
||||||
@ -386,7 +385,6 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
|
|||||||
goal: newSprint.goal,
|
goal: newSprint.goal,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
status: inferSprintStatusForDateRange(startDate, endDate),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
setIsCreatingSprint(false)
|
setIsCreatingSprint(false)
|
||||||
|
|||||||
@ -24,7 +24,6 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Plus, Calendar, Flag, GripVertical } from "lucide-react"
|
import { Plus, Calendar, Flag, GripVertical } from "lucide-react"
|
||||||
import { format, isValid } from "date-fns"
|
import { format, isValid } from "date-fns"
|
||||||
import {
|
import {
|
||||||
inferSprintStatusForDateRange,
|
|
||||||
parseSprintEnd,
|
parseSprintEnd,
|
||||||
parseSprintStart,
|
parseSprintStart,
|
||||||
toLocalDateInputValue,
|
toLocalDateInputValue,
|
||||||
@ -210,12 +209,11 @@ export function SprintBoard() {
|
|||||||
|
|
||||||
const startDate = newSprint.startDate || toLocalDateInputValue()
|
const startDate = newSprint.startDate || toLocalDateInputValue()
|
||||||
const endDate = newSprint.endDate || toLocalDateInputValue()
|
const endDate = newSprint.endDate || toLocalDateInputValue()
|
||||||
const sprint: Omit<Sprint, "id" | "createdAt"> = {
|
const sprint: Omit<Sprint, "id" | "createdAt" | "status"> = {
|
||||||
name: newSprint.name,
|
name: newSprint.name,
|
||||||
goal: newSprint.goal,
|
goal: newSprint.goal,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
status: inferSprintStatusForDateRange(startDate, endDate),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addSprint(sprint)
|
addSprint(sprint)
|
||||||
|
|||||||
@ -1,26 +1,9 @@
|
|||||||
|
import { parseSprintEnd, parseSprintStart } from "@/lib/server/sprintState"
|
||||||
|
|
||||||
export interface SprintLike {
|
export interface SprintLike {
|
||||||
id: string;
|
id: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
status: "planning" | "active" | "completed";
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSprintStart(value: string): Date {
|
|
||||||
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})/)
|
|
||||||
if (match) {
|
|
||||||
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 0, 0, 0, 0)
|
|
||||||
}
|
|
||||||
return new Date(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSprintEnd(value: string): Date {
|
|
||||||
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})/)
|
|
||||||
if (match) {
|
|
||||||
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 23, 59, 59, 999)
|
|
||||||
}
|
|
||||||
const parsed = new Date(value)
|
|
||||||
parsed.setHours(23, 59, 59, 999)
|
|
||||||
return parsed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSprintInProgress(startDate: string, endDate: string, now: Date = new Date()): boolean {
|
export function isSprintInProgress(startDate: string, endDate: string, now: Date = new Date()): boolean {
|
||||||
@ -33,16 +16,19 @@ export function findCurrentSprint<T extends SprintLike>(
|
|||||||
sprints: T[],
|
sprints: T[],
|
||||||
options?: {
|
options?: {
|
||||||
now?: Date
|
now?: Date
|
||||||
includeCompletedFallback?: boolean
|
|
||||||
}
|
}
|
||||||
): T | null {
|
): T | null {
|
||||||
const now = options?.now ?? new Date()
|
const now = options?.now ?? new Date()
|
||||||
const includeCompletedFallback = options?.includeCompletedFallback ?? false
|
|
||||||
const inProgress = sprints.filter((s) => isSprintInProgress(s.startDate, s.endDate, now))
|
const inProgress = sprints.filter((s) => isSprintInProgress(s.startDate, s.endDate, now))
|
||||||
|
|
||||||
return (
|
if (inProgress.length === 0) return null
|
||||||
inProgress.find((s) => s.status === "active") ??
|
|
||||||
inProgress.find((s) => s.status !== "completed") ??
|
// When date windows overlap, select the most recently started sprint.
|
||||||
(includeCompletedFallback ? inProgress[0] ?? null : null)
|
return inProgress
|
||||||
)
|
.slice()
|
||||||
|
.sort((a, b) => {
|
||||||
|
const startDelta = parseSprintStart(b.startDate).getTime() - parseSprintStart(a.startDate).getTime()
|
||||||
|
if (startDelta !== 0) return startDelta
|
||||||
|
return parseSprintEnd(a.endDate).getTime() - parseSprintEnd(b.endDate).getTime()
|
||||||
|
})[0]
|
||||||
}
|
}
|
||||||
|
|||||||
59
src/lib/server/sprintState.ts
Normal file
59
src/lib/server/sprintState.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
export type DerivedSprintStatus = "planning" | "active" | "completed"
|
||||||
|
|
||||||
|
const DATE_PREFIX_PATTERN = /^(\d{4})-(\d{2})-(\d{2})/
|
||||||
|
|
||||||
|
function parseDateParts(value: string): [number, number, number] | null {
|
||||||
|
const match = value.match(DATE_PREFIX_PATTERN)
|
||||||
|
if (!match) return null
|
||||||
|
return [Number(match[1]), Number(match[2]), Number(match[3])]
|
||||||
|
}
|
||||||
|
|
||||||
|
function asLocalDayDate(value: string, endOfDay: boolean): Date | null {
|
||||||
|
const parts = parseDateParts(value)
|
||||||
|
if (!parts) return null
|
||||||
|
const [year, month, day] = parts
|
||||||
|
return endOfDay
|
||||||
|
? new Date(year, month - 1, day, 23, 59, 59, 999)
|
||||||
|
: new Date(year, month - 1, day, 0, 0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSprintStart(value: string): Date {
|
||||||
|
const parsed = asLocalDayDate(value, false)
|
||||||
|
if (parsed) return parsed
|
||||||
|
return new Date(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSprintEnd(value: string): Date {
|
||||||
|
const parsed = asLocalDayDate(value, true)
|
||||||
|
if (parsed) return parsed
|
||||||
|
const fallback = new Date(value)
|
||||||
|
fallback.setHours(23, 59, 59, 999)
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferSprintStatusForDateRange(
|
||||||
|
startDate: string,
|
||||||
|
endDate: string,
|
||||||
|
now: Date = new Date()
|
||||||
|
): DerivedSprintStatus {
|
||||||
|
const sprintStart = parseSprintStart(startDate)
|
||||||
|
if (now < sprintStart) return "planning"
|
||||||
|
|
||||||
|
const sprintEnd = parseSprintEnd(endDate)
|
||||||
|
if (now > sprintEnd) return "completed"
|
||||||
|
|
||||||
|
return "active"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toLocalDateOnly(now: Date = new Date()): string {
|
||||||
|
const year = now.getFullYear()
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, "0")
|
||||||
|
const day = String(now.getDate()).padStart(2, "0")
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays(date: Date, days: number): Date {
|
||||||
|
const next = new Date(date)
|
||||||
|
next.setDate(next.getDate() + days)
|
||||||
|
return next
|
||||||
|
}
|
||||||
@ -131,7 +131,6 @@ export interface Database {
|
|||||||
goal: string | null;
|
goal: string | null;
|
||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string;
|
end_date: string;
|
||||||
status: 'planning' | 'active' | 'completed';
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
Insert: {
|
Insert: {
|
||||||
@ -140,7 +139,6 @@ export interface Database {
|
|||||||
goal?: string | null;
|
goal?: string | null;
|
||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string;
|
end_date: string;
|
||||||
status: 'planning' | 'active' | 'completed';
|
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
};
|
};
|
||||||
Update: {
|
Update: {
|
||||||
@ -149,7 +147,6 @@ export interface Database {
|
|||||||
goal?: string | null;
|
goal?: string | null;
|
||||||
start_date?: string;
|
start_date?: string;
|
||||||
end_date?: string;
|
end_date?: string;
|
||||||
status?: 'planning' | 'active' | 'completed';
|
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -105,7 +105,7 @@ interface TaskStore {
|
|||||||
selectTask: (id: string | null) => void
|
selectTask: (id: string | null) => void
|
||||||
|
|
||||||
// Sprint actions
|
// Sprint actions
|
||||||
addSprint: (sprint: Omit<Sprint, 'id' | 'createdAt'>) => void
|
addSprint: (sprint: Omit<Sprint, 'id' | 'createdAt' | 'status'>) => void
|
||||||
updateSprint: (id: string, updates: Partial<Sprint>) => void
|
updateSprint: (id: string, updates: Partial<Sprint>) => void
|
||||||
deleteSprint: (id: string) => void
|
deleteSprint: (id: string) => void
|
||||||
selectSprint: (id: string | null) => void
|
selectSprint: (id: string | null) => void
|
||||||
@ -547,10 +547,16 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
addSprint: (sprint) => {
|
addSprint: (sprint) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: sprint.name,
|
||||||
|
goal: sprint.goal,
|
||||||
|
startDate: sprint.startDate,
|
||||||
|
endDate: sprint.endDate,
|
||||||
|
}
|
||||||
await requestApi('/api/sprints', {
|
await requestApi('/api/sprints', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(sprint),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
await get().syncFromServer()
|
await get().syncFromServer()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -564,10 +570,16 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
updateSprint: (id, updates) => {
|
updateSprint: (id, updates) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
const payload: Record<string, unknown> = { id }
|
||||||
|
if (updates.name !== undefined) payload.name = updates.name
|
||||||
|
if (updates.goal !== undefined) payload.goal = updates.goal
|
||||||
|
if (updates.startDate !== undefined) payload.startDate = updates.startDate
|
||||||
|
if (updates.endDate !== undefined) payload.endDate = updates.endDate
|
||||||
|
|
||||||
await requestApi('/api/sprints', {
|
await requestApi('/api/sprints', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id, ...updates }),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
await get().syncFromServer()
|
await get().syncFromServer()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -16,15 +16,14 @@ BEGIN
|
|||||||
FOR ended_sprint IN
|
FOR ended_sprint IN
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM public.sprints
|
FROM public.sprints
|
||||||
WHERE status <> 'completed'
|
WHERE 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 globally.
|
-- Pick the next sprint that has not yet ended.
|
||||||
SELECT s.id
|
SELECT s.id
|
||||||
INTO next_sprint_id
|
INTO next_sprint_id
|
||||||
FROM public.sprints s
|
FROM public.sprints s
|
||||||
WHERE s.status <> 'completed'
|
WHERE s.end_date >= CURRENT_DATE
|
||||||
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;
|
||||||
@ -36,9 +35,6 @@ BEGIN
|
|||||||
AND status NOT IN ('done', 'canceled', 'archived');
|
AND status NOT IN ('done', 'canceled', 'archived');
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
UPDATE public.sprints
|
|
||||||
SET status = 'completed'
|
|
||||||
WHERE id = ended_sprint.id;
|
|
||||||
END LOOP;
|
END LOOP;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|||||||
@ -6,6 +6,7 @@ BEGIN;
|
|||||||
DROP INDEX IF EXISTS idx_sprints_project_id;
|
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 CONSTRAINT IF EXISTS sprints_project_id_fkey;
|
||||||
ALTER TABLE public.sprints DROP COLUMN IF EXISTS project_id;
|
ALTER TABLE public.sprints DROP COLUMN IF EXISTS project_id;
|
||||||
|
ALTER TABLE public.sprints DROP COLUMN IF EXISTS status;
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.complete_ended_sprints_and_rollover()
|
CREATE OR REPLACE FUNCTION public.complete_ended_sprints_and_rollover()
|
||||||
RETURNS void
|
RETURNS void
|
||||||
@ -20,14 +21,13 @@ BEGIN
|
|||||||
FOR ended_sprint IN
|
FOR ended_sprint IN
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM public.sprints
|
FROM public.sprints
|
||||||
WHERE status <> 'completed'
|
WHERE 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
|
||||||
SELECT s.id
|
SELECT s.id
|
||||||
INTO next_sprint_id
|
INTO next_sprint_id
|
||||||
FROM public.sprints s
|
FROM public.sprints s
|
||||||
WHERE s.status <> 'completed'
|
WHERE s.end_date >= CURRENT_DATE
|
||||||
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;
|
||||||
@ -39,9 +39,6 @@ BEGIN
|
|||||||
AND status NOT IN ('done', 'canceled', 'archived');
|
AND status NOT IN ('done', 'canceled', 'archived');
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
UPDATE public.sprints
|
|
||||||
SET status = 'completed'
|
|
||||||
WHERE id = ended_sprint.id;
|
|
||||||
END LOOP;
|
END LOOP;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|||||||
42
supabase/remove_sprint_status.sql
Normal file
42
supabase/remove_sprint_status.sql
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
-- Removes persisted sprint status; sprint state is derived from start/end dates.
|
||||||
|
-- Safe to run multiple times.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE public.sprints DROP COLUMN IF EXISTS status;
|
||||||
|
|
||||||
|
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 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.end_date >= CURRENT_DATE
|
||||||
|
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;
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@ -122,7 +122,6 @@ CREATE TABLE IF NOT EXISTS sprints (
|
|||||||
goal TEXT,
|
goal TEXT,
|
||||||
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')),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -261,15 +260,14 @@ BEGIN
|
|||||||
FOR ended_sprint IN
|
FOR ended_sprint IN
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM public.sprints
|
FROM public.sprints
|
||||||
WHERE status <> 'completed'
|
WHERE 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 globally.
|
-- Pick the next sprint that has not yet ended.
|
||||||
SELECT s.id
|
SELECT s.id
|
||||||
INTO next_sprint_id
|
INTO next_sprint_id
|
||||||
FROM public.sprints s
|
FROM public.sprints s
|
||||||
WHERE s.status <> 'completed'
|
WHERE s.end_date >= CURRENT_DATE
|
||||||
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;
|
||||||
@ -281,9 +279,6 @@ BEGIN
|
|||||||
AND status NOT IN ('done', 'canceled', 'archived');
|
AND status NOT IN ('done', 'canceled', 'archived');
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
UPDATE public.sprints
|
|
||||||
SET status = 'completed'
|
|
||||||
WHERE id = ended_sprint.id;
|
|
||||||
END LOOP;
|
END LOOP;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user