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
|
||||
./sprint.sh list [--status <planning|active|completed>] [--active] [--json]
|
||||
./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 update <sprint-id-or-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]
|
||||
./sprint.sh close <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`
|
||||
- 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
|
||||
|
||||
|
||||
@ -401,8 +401,7 @@ cmd_sprint_create() {
|
||||
name: $name,
|
||||
startDate: (if $startDate == "" then null else $startDate end),
|
||||
endDate: (if $endDate == "" then null else $endDate end),
|
||||
goal: (if $goal == "" then null else $goal end),
|
||||
status: "planning"
|
||||
goal: (if $goal == "" then null else $goal end)
|
||||
}')
|
||||
api_call POST "/sprints" "$data"
|
||||
}
|
||||
@ -414,7 +413,12 @@ cmd_sprint_update() {
|
||||
|
||||
if [ -z "$sprint_id" ] || [ -z "$field" ]; then
|
||||
log_error "Usage: sprint update <id> <field> <value>"
|
||||
echo "Fields: name, goal, startDate, endDate, status"
|
||||
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
|
||||
fi
|
||||
|
||||
@ -591,8 +595,7 @@ SPRINT COMMANDS:
|
||||
Create new sprint
|
||||
sprint update <id> <field> <val>
|
||||
Update sprint field
|
||||
Fields: name, goal, startDate, endDate,
|
||||
status
|
||||
Fields: name, goal, startDate, endDate
|
||||
sprint close <id> Close a sprint
|
||||
sprint delete <id> Delete a sprint
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Sprint Auto-Status and Rollover Script
|
||||
# Updates sprint statuses based on current date and rolls over incomplete tasks
|
||||
# Sprint Rollover Script
|
||||
# Sprint state is date-derived; this script only handles task rollover for ended sprints.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@ -39,25 +39,6 @@ get_sprint_target_status() {
|
||||
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() {
|
||||
local sprint_id="$1"
|
||||
@ -134,7 +115,7 @@ move_task_to_sprint() {
|
||||
|
||||
# Main function to process sprint updates
|
||||
process_sprint_updates() {
|
||||
log "=== Starting Sprint Auto-Status Update ==="
|
||||
log "=== Starting Sprint Rollover Run ==="
|
||||
log "Today: $(get_today)"
|
||||
|
||||
# Get all sprints
|
||||
@ -146,7 +127,6 @@ process_sprint_updates() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
local updated_count=0
|
||||
local rollover_count=0
|
||||
|
||||
# Process each sprint
|
||||
@ -154,13 +134,11 @@ process_sprint_updates() {
|
||||
[[ -z "$sprint" ]] && continue
|
||||
|
||||
local sprint_id
|
||||
local current_status
|
||||
local start_date
|
||||
local end_date
|
||||
local sprint_name
|
||||
|
||||
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')
|
||||
end_date=$(echo "$sprint" | jq -r '.end_date // .endDate // empty')
|
||||
sprint_name=$(echo "$sprint" | jq -r '.name // "Unnamed"')
|
||||
@ -171,58 +149,48 @@ process_sprint_updates() {
|
||||
local target_status
|
||||
target_status=$(get_sprint_target_status "$start_date" "$end_date")
|
||||
|
||||
log "Sprint: $sprint_name (ID: $sprint_id) - Current: $current_status, Target: $target_status"
|
||||
|
||||
# Update if different
|
||||
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
|
||||
log "Sprint $sprint_name completed - checking for tasks to roll over"
|
||||
|
||||
# Get the new current sprint
|
||||
local current_sprint
|
||||
current_sprint=$(get_current_active_sprint)
|
||||
|
||||
if [[ -n "$current_sprint" && "$current_sprint" != "null" ]]; then
|
||||
local new_sprint_id
|
||||
new_sprint_id=$(echo "$current_sprint" | jq -r '.id')
|
||||
|
||||
# Get incomplete tasks
|
||||
local incomplete_tasks
|
||||
incomplete_tasks=$(get_incomplete_tasks "$sprint_id")
|
||||
|
||||
local task_count
|
||||
task_count=$(echo "$incomplete_tasks" | jq 'length')
|
||||
|
||||
if [[ "$task_count" -gt 0 ]]; then
|
||||
log "Found $task_count incomplete tasks to roll over"
|
||||
|
||||
# Move each task
|
||||
while IFS= read -r task_id; do
|
||||
[[ -z "$task_id" ]] && continue
|
||||
if move_task_to_sprint "$task_id" "$new_sprint_id" "$sprint_name"; then
|
||||
((rollover_count++))
|
||||
fi
|
||||
done < <(echo "$incomplete_tasks" | jq -r '.[].id' 2>/dev/null)
|
||||
else
|
||||
log "No incomplete tasks found"
|
||||
log "Sprint: $sprint_name (ID: $sprint_id) - Derived status: $target_status"
|
||||
|
||||
# Rollover applies only to completed (ended) sprints.
|
||||
if [[ "$target_status" == "completed" ]]; then
|
||||
log "Sprint $sprint_name has ended - checking for tasks to roll over"
|
||||
|
||||
# Get the new current sprint
|
||||
local current_sprint
|
||||
current_sprint=$(get_current_active_sprint)
|
||||
|
||||
if [[ -n "$current_sprint" && "$current_sprint" != "null" ]]; then
|
||||
local new_sprint_id
|
||||
new_sprint_id=$(echo "$current_sprint" | jq -r '.id')
|
||||
|
||||
# Get incomplete tasks
|
||||
local incomplete_tasks
|
||||
incomplete_tasks=$(get_incomplete_tasks "$sprint_id")
|
||||
|
||||
local task_count
|
||||
task_count=$(echo "$incomplete_tasks" | jq 'length')
|
||||
|
||||
if [[ "$task_count" -gt 0 ]]; then
|
||||
log "Found $task_count incomplete tasks to roll over"
|
||||
|
||||
# Move each task
|
||||
while IFS= read -r task_id; do
|
||||
[[ -z "$task_id" ]] && continue
|
||||
if move_task_to_sprint "$task_id" "$new_sprint_id" "$sprint_name"; then
|
||||
((rollover_count++))
|
||||
fi
|
||||
else
|
||||
log "Warning: No current active sprint found for rollover"
|
||||
fi
|
||||
done < <(echo "$incomplete_tasks" | jq -r '.[].id' 2>/dev/null)
|
||||
else
|
||||
log "No incomplete tasks found"
|
||||
fi
|
||||
else
|
||||
log "Warning: No current active sprint found for rollover"
|
||||
fi
|
||||
else
|
||||
log "Status is correct, no update needed"
|
||||
fi
|
||||
|
||||
done < <(echo "$sprints" | jq -c '.[]' 2>/dev/null)
|
||||
|
||||
log "=== Sprint Update Complete ==="
|
||||
log "Updated: $updated_count sprints"
|
||||
log "Rolled over: $rollover_count tasks"
|
||||
|
||||
return 0
|
||||
@ -231,13 +199,13 @@ process_sprint_updates() {
|
||||
# Show help
|
||||
show_help() {
|
||||
cat << 'HELP'
|
||||
Sprint Auto-Status and Rollover Script
|
||||
Sprint Rollover Script
|
||||
|
||||
USAGE:
|
||||
./sprint-auto-status.sh [command]
|
||||
|
||||
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
|
||||
cleanup Clean up old log files
|
||||
help Show this help message
|
||||
@ -260,8 +228,8 @@ case "${1:-}" in
|
||||
dry-run)
|
||||
log "=== DRY RUN MODE - No changes will be made ==="
|
||||
# In dry-run mode, we'd print what would happen
|
||||
log "Would check all sprints and update statuses based on $(get_today)"
|
||||
log "Would roll over incomplete tasks from completed sprints"
|
||||
log "Would check all sprints and derive state from dates on $(get_today)"
|
||||
log "Would roll over incomplete tasks from ended sprints"
|
||||
;;
|
||||
cleanup)
|
||||
rm -f /tmp/sprint-auto-status.log
|
||||
@ -275,4 +243,4 @@ case "${1:-}" in
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
esac
|
||||
|
||||
@ -48,7 +48,6 @@ CREATE OPTIONS:
|
||||
--goal "Goal" Sprint goal
|
||||
--start-date "YYYY-MM-DD" Start date
|
||||
--end-date "YYYY-MM-DD" End date
|
||||
--status [planning|active|completed] Status (default: planning)
|
||||
|
||||
LIST OPTIONS:
|
||||
--status <status> Filter by status
|
||||
@ -89,7 +88,6 @@ cmd_create() {
|
||||
local goal=""
|
||||
local start_date=""
|
||||
local end_date=""
|
||||
local status="planning"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "${1:-}" in
|
||||
@ -97,7 +95,6 @@ cmd_create() {
|
||||
--goal) goal="${2:-}"; shift 2 ;;
|
||||
--start-date) start_date="${2:-}"; shift 2 ;;
|
||||
--end-date) end_date="${2:-}"; shift 2 ;;
|
||||
--status) status="${2:-}"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
@ -113,13 +110,11 @@ cmd_create() {
|
||||
--arg goal "$goal" \
|
||||
--arg startDate "$start_date" \
|
||||
--arg endDate "$end_date" \
|
||||
--arg status "$status" \
|
||||
'{
|
||||
name: $name,
|
||||
goal: (if $goal == "" then null else $goal end),
|
||||
startDate: (if $startDate == "" then null else $startDate end),
|
||||
endDate: (if $endDate == "" then null else $endDate end),
|
||||
status: $status
|
||||
endDate: (if $endDate == "" then null else $endDate end)
|
||||
}')
|
||||
|
||||
log_info "Creating sprint..."
|
||||
@ -146,10 +141,7 @@ cmd_list() {
|
||||
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[*]}")"
|
||||
@ -212,7 +204,6 @@ cmd_update() {
|
||||
--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 ;;
|
||||
--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 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
@ -106,7 +106,7 @@ case "${method} ${url}" in
|
||||
respond '{"success":true}' 200
|
||||
;;
|
||||
"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
|
||||
;;
|
||||
"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" 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" 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" 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 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 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 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/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/current"
|
||||
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)
|
||||
})
|
||||
|
||||
test("findCurrentSprint prefers active sprint in range", () => {
|
||||
test("findCurrentSprint returns in-range sprint regardless of status", () => {
|
||||
const sprint = findCurrentSprint(
|
||||
[
|
||||
{ id: "planning", status: "planning", startDate: "2026-02-20", endDate: "2026-02-28" },
|
||||
{ id: "active", status: "active", startDate: "2026-02-22", endDate: "2026-02-26" },
|
||||
{ id: "done", status: "completed", startDate: "2026-02-20", endDate: "2026-02-28" },
|
||||
],
|
||||
{ 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(
|
||||
[{ 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 }
|
||||
)
|
||||
|
||||
assert.equal(sprint?.id, "planning")
|
||||
assert.equal(sprint?.id, "latest")
|
||||
})
|
||||
|
||||
test("findCurrentSprint returns null for completed-only unless fallback enabled", () => {
|
||||
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", () => {
|
||||
test("findCurrentSprint keeps deterministic order when date windows are identical", () => {
|
||||
const sprint = findCurrentSprint(
|
||||
[
|
||||
{ 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 }
|
||||
)
|
||||
|
||||
assert.equal(sprint?.id, "active")
|
||||
assert.equal(sprint?.id, "planning")
|
||||
})
|
||||
|
||||
test("findCurrentSprint returns null when no sprint is in range", () => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServiceSupabase } from "@/lib/supabase/client";
|
||||
import { getAuthenticatedUser } from "@/lib/server/auth";
|
||||
import { inferSprintStatusForDateRange } from "@/lib/server/sprintState";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@ -31,7 +32,17 @@ export async function GET(
|
||||
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) {
|
||||
console.error(">>> API GET /sprints/[id] error:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch sprint" }, { status: 500 });
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getServiceSupabase } from "@/lib/supabase/client"
|
||||
import { getAuthenticatedUser } from "@/lib/server/auth"
|
||||
import { addDays, inferSprintStatusForDateRange, toLocalDateOnly } from "@/lib/server/sprintState"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
@ -19,16 +20,28 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const supabase = getServiceSupabase()
|
||||
const closedEndDate = toLocalDateOnly(addDays(new Date(), -1))
|
||||
const { data, error } = await supabase
|
||||
.from("sprints")
|
||||
.update({ status: "completed" })
|
||||
.update({ end_date: closedEndDate })
|
||||
.eq("id", id)
|
||||
.select("*")
|
||||
.single()
|
||||
|
||||
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) {
|
||||
console.error(">>> API POST /sprints/close error:", error)
|
||||
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 { getAuthenticatedUser } from "@/lib/server/auth"
|
||||
import { findCurrentSprint } from "@/lib/server/sprintSelection"
|
||||
import { inferSprintStatusForDateRange } from "@/lib/server/sprintState"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getAuthenticatedUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const includeCompletedFallback = searchParams.get("includeCompletedFallback") === "true"
|
||||
|
||||
const supabase = getServiceSupabase()
|
||||
const { data, error } = await supabase
|
||||
.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 })
|
||||
|
||||
if (error) throw error
|
||||
@ -29,11 +27,11 @@ export async function GET(request: Request) {
|
||||
goal: row.goal,
|
||||
startDate: row.start_date,
|
||||
endDate: row.end_date,
|
||||
status: row.status,
|
||||
status: inferSprintStatusForDateRange(row.start_date, row.end_date),
|
||||
createdAt: row.created_at,
|
||||
}))
|
||||
|
||||
const sprint = findCurrentSprint(mapped, { includeCompletedFallback })
|
||||
const sprint = findCurrentSprint(mapped)
|
||||
return NextResponse.json({ sprint })
|
||||
} catch (error) {
|
||||
console.error(">>> API GET /sprints/current error:", error)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServiceSupabase } from "@/lib/supabase/client";
|
||||
import { getAuthenticatedUser } from "@/lib/server/auth";
|
||||
import { inferSprintStatusForDateRange, toLocalDateOnly } from "@/lib/server/sprintState";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@ -55,11 +56,26 @@ function requireUuid(value: unknown, field: string): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function requireSprintStatus(value: unknown, field: string): SprintStatus {
|
||||
if (typeof value !== "string" || !SPRINT_STATUSES.includes(value as SprintStatus)) {
|
||||
throw new HttpError(400, `${field} must be one of: ${SPRINT_STATUSES.join(", ")}`, { field, value });
|
||||
function requireRowString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
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)
|
||||
@ -82,6 +98,7 @@ export async function GET(request: Request) {
|
||||
if (onDateInput && !onDate) {
|
||||
throw new HttpError(400, "onDate must be YYYY-MM-DD or ISO date-time", { onDate: onDateInput });
|
||||
}
|
||||
const filterDate = onDate || toLocalDateOnly();
|
||||
|
||||
const supabase = getServiceSupabase();
|
||||
let query = supabase
|
||||
@ -89,20 +106,27 @@ export async function GET(request: Request) {
|
||||
.select("*")
|
||||
.order("start_date", { ascending: true });
|
||||
|
||||
// Filter by status if provided
|
||||
// Filter by derived status if provided (status is inferred from date range).
|
||||
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) {
|
||||
const resolvedDate = onDate || new Date().toISOString().slice(0, 10);
|
||||
query = query.lte("start_date", resolvedDate).gte("end_date", resolvedDate);
|
||||
query = query.lte("start_date", filterDate).gte("end_date", filterDate);
|
||||
}
|
||||
|
||||
const { data: sprints, error } = await query;
|
||||
|
||||
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) {
|
||||
console.error(">>> API GET /sprints error:", error);
|
||||
if (error instanceof HttpError) {
|
||||
@ -121,7 +145,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, goal, startDate, endDate, status } = body;
|
||||
const { name, goal, startDate, endDate } = body;
|
||||
|
||||
const supabase = getServiceSupabase();
|
||||
const resolvedName = requireNonEmptyString(name, "name");
|
||||
@ -133,7 +157,6 @@ export async function POST(request: Request) {
|
||||
if (!normalizedEndDate) {
|
||||
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 { data, error } = await supabase
|
||||
@ -143,7 +166,6 @@ export async function POST(request: Request) {
|
||||
goal: goal || null,
|
||||
start_date: normalizedStartDate,
|
||||
end_date: normalizedEndDate,
|
||||
status: resolvedStatus,
|
||||
created_at: now,
|
||||
})
|
||||
.select()
|
||||
@ -151,7 +173,10 @@ export async function POST(request: Request) {
|
||||
|
||||
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) {
|
||||
console.error(">>> API POST /sprints error:", error);
|
||||
if (error instanceof HttpError) {
|
||||
@ -193,7 +218,25 @@ export async function PATCH(request: Request) {
|
||||
}
|
||||
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
|
||||
.from("sprints")
|
||||
@ -204,7 +247,10 @@ export async function PATCH(request: Request) {
|
||||
|
||||
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) {
|
||||
console.error(">>> API PATCH /sprints error:", error);
|
||||
if (error instanceof HttpError) {
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { getServiceSupabase } from "@/lib/supabase/client";
|
||||
import { getAuthenticatedUser } from "@/lib/server/auth";
|
||||
import { findCurrentSprint } from "@/lib/server/sprintSelection";
|
||||
import { inferSprintStatusForDateRange } from "@/lib/server/sprintState";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@ -60,7 +61,6 @@ interface UserProfile {
|
||||
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_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;
|
||||
|
||||
// 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 {
|
||||
const startDate = requireNonEmptyString(row.start_date, "sprints.start_date", 500);
|
||||
const endDate = requireNonEmptyString(row.end_date, "sprints.end_date", 500);
|
||||
return {
|
||||
id: requireNonEmptyString(row.id, "sprints.id", 500),
|
||||
name: requireNonEmptyString(row.name, "sprints.name", 500),
|
||||
goal: toNonEmptyString(row.goal),
|
||||
startDate: requireNonEmptyString(row.start_date, "sprints.start_date", 500),
|
||||
endDate: requireNonEmptyString(row.end_date, "sprints.end_date", 500),
|
||||
status: requireEnum(row.status, SPRINT_STATUSES, "sprints.status", 500),
|
||||
startDate,
|
||||
endDate,
|
||||
status: inferSprintStatusForDateRange(startDate, endDate),
|
||||
createdAt: requireNonEmptyString(row.created_at, "sprints.created_at", 500),
|
||||
};
|
||||
}
|
||||
@ -304,7 +306,7 @@ export async function GET(request: Request) {
|
||||
{ data: users, error: usersError }
|
||||
] = await Promise.all([
|
||||
supabase.from("projects").select("id, name, description, color, created_at").order("created_at", { ascending: true }),
|
||||
supabase.from("sprints").select("id, name, goal, start_date, end_date, status, 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"),
|
||||
]);
|
||||
|
||||
|
||||
@ -379,7 +379,6 @@ export default function Home() {
|
||||
setCurrentUser,
|
||||
syncFromServer,
|
||||
isLoading,
|
||||
updateSprint,
|
||||
} = useTaskStore()
|
||||
|
||||
const [newTaskOpen, setNewTaskOpen] = useState(false)
|
||||
@ -634,13 +633,11 @@ export default function Home() {
|
||||
const now = new Date()
|
||||
const sprintsInProgress = sprints.filter((s) => isSprintInProgress(s.startDate, s.endDate, now))
|
||||
|
||||
// First: prefer active sprints that are in date range
|
||||
// Second: any sprint in date range (not completed)
|
||||
// Third: any sprint in date range (even if completed, for edge cases)
|
||||
// Current sprint is selected by date window only.
|
||||
const currentSprint =
|
||||
sprintsInProgress.find((s) => s.status === "active") ??
|
||||
sprintsInProgress.find((s) => s.status !== "completed") ??
|
||||
sprintsInProgress[0] ??
|
||||
sprintsInProgress
|
||||
.slice()
|
||||
.sort((a, b) => parseSprintStart(b.startDate).getTime() - parseSprintStart(a.startDate).getTime())[0] ??
|
||||
null
|
||||
|
||||
// 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
|
||||
|
||||
const now = new Date()
|
||||
const endedSprints = sprints.filter((s) => {
|
||||
if (s.status === "completed") return false
|
||||
const sprintEnd = parseSprintEnd(s.endDate)
|
||||
return sprintEnd < now
|
||||
})
|
||||
const endedSprints = sprints.filter((s) => parseSprintEnd(s.endDate) < now)
|
||||
|
||||
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
|
||||
.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]
|
||||
|
||||
if (!nextSprint) return
|
||||
@ -695,14 +688,9 @@ export default function Home() {
|
||||
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
|
||||
? 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 { Textarea } from "@/components/ui/textarea"
|
||||
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"
|
||||
|
||||
type SprintDraft = {
|
||||
@ -19,7 +19,6 @@ type SprintDraft = {
|
||||
goal: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
status: SprintStatus
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<SprintStatus, string> = {
|
||||
@ -51,7 +50,6 @@ function buildDefaultDraft(): SprintDraft {
|
||||
goal: "",
|
||||
startDate,
|
||||
endDate,
|
||||
status: inferSprintStatusForDateRange(startDate, endDate),
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,7 +154,6 @@ export default function SprintsPage() {
|
||||
goal: sprint.goal || "",
|
||||
startDate: sprint.startDate,
|
||||
endDate: sprint.endDate,
|
||||
status: sprint.status,
|
||||
})
|
||||
setEditorOpen(true)
|
||||
}
|
||||
@ -190,7 +187,6 @@ export default function SprintsPage() {
|
||||
goal: draft.goal.trim() || undefined,
|
||||
startDate: draft.startDate,
|
||||
endDate: draft.endDate,
|
||||
status: draft.status,
|
||||
}
|
||||
|
||||
if (editorMode === "edit") {
|
||||
@ -459,7 +455,6 @@ export default function SprintsPage() {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
startDate,
|
||||
status: inferSprintStatusForDateRange(startDate, prev.endDate),
|
||||
}))
|
||||
}}
|
||||
className="mt-1 bg-slate-800 border-slate-700 text-white"
|
||||
@ -475,7 +470,6 @@ export default function SprintsPage() {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
endDate,
|
||||
status: inferSprintStatusForDateRange(prev.startDate, endDate),
|
||||
}))
|
||||
}}
|
||||
className="mt-1 bg-slate-800 border-slate-700 text-white"
|
||||
@ -483,19 +477,6 @@ export default function SprintsPage() {
|
||||
</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"
|
||||
|
||||
@ -26,7 +26,6 @@ import { Button } from "@/components/ui/button"
|
||||
import { Plus, GripVertical, ChevronDown, ChevronRight, Calendar } from "lucide-react"
|
||||
import { format, isValid } from "date-fns"
|
||||
import {
|
||||
inferSprintStatusForDateRange,
|
||||
isSprintInProgress,
|
||||
parseSprintEnd,
|
||||
parseSprintStart,
|
||||
@ -319,9 +318,9 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
|
||||
? tasks.filter((t) => t.projectId === selectedProjectId)
|
||||
: tasks
|
||||
|
||||
const currentSprint =
|
||||
projectSprints.find((s) => s.status === "active" && isSprintInProgress(s.startDate, s.endDate, now)) ??
|
||||
projectSprints.find((s) => s.status !== "completed" && isSprintInProgress(s.startDate, s.endDate, now))
|
||||
const currentSprint = projectSprints
|
||||
.filter((s) => isSprintInProgress(s.startDate, s.endDate, now))
|
||||
.sort((a, b) => parseSprintStart(b.startDate).getTime() - parseSprintStart(a.startDate).getTime())[0]
|
||||
|
||||
// Get other sprints (not current)
|
||||
const otherSprints = projectSprints.filter((s) => s.id !== currentSprint?.id)
|
||||
@ -386,7 +385,6 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
|
||||
goal: newSprint.goal,
|
||||
startDate,
|
||||
endDate,
|
||||
status: inferSprintStatusForDateRange(startDate, endDate),
|
||||
})
|
||||
|
||||
setIsCreatingSprint(false)
|
||||
|
||||
@ -24,7 +24,6 @@ import { Button } from "@/components/ui/button"
|
||||
import { Plus, Calendar, Flag, GripVertical } from "lucide-react"
|
||||
import { format, isValid } from "date-fns"
|
||||
import {
|
||||
inferSprintStatusForDateRange,
|
||||
parseSprintEnd,
|
||||
parseSprintStart,
|
||||
toLocalDateInputValue,
|
||||
@ -210,12 +209,11 @@ export function SprintBoard() {
|
||||
|
||||
const startDate = newSprint.startDate || toLocalDateInputValue()
|
||||
const endDate = newSprint.endDate || toLocalDateInputValue()
|
||||
const sprint: Omit<Sprint, "id" | "createdAt"> = {
|
||||
const sprint: Omit<Sprint, "id" | "createdAt" | "status"> = {
|
||||
name: newSprint.name,
|
||||
goal: newSprint.goal,
|
||||
startDate,
|
||||
endDate,
|
||||
status: inferSprintStatusForDateRange(startDate, endDate),
|
||||
}
|
||||
|
||||
addSprint(sprint)
|
||||
|
||||
@ -1,26 +1,9 @@
|
||||
import { parseSprintEnd, parseSprintStart } from "@/lib/server/sprintState"
|
||||
|
||||
export interface SprintLike {
|
||||
id: string;
|
||||
startDate: 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 {
|
||||
@ -33,16 +16,19 @@ export function findCurrentSprint<T extends SprintLike>(
|
||||
sprints: T[],
|
||||
options?: {
|
||||
now?: Date
|
||||
includeCompletedFallback?: boolean
|
||||
}
|
||||
): T | null {
|
||||
const now = options?.now ?? new Date()
|
||||
const includeCompletedFallback = options?.includeCompletedFallback ?? false
|
||||
const inProgress = sprints.filter((s) => isSprintInProgress(s.startDate, s.endDate, now))
|
||||
|
||||
return (
|
||||
inProgress.find((s) => s.status === "active") ??
|
||||
inProgress.find((s) => s.status !== "completed") ??
|
||||
(includeCompletedFallback ? inProgress[0] ?? null : null)
|
||||
)
|
||||
if (inProgress.length === 0) return null
|
||||
|
||||
// When date windows overlap, select the most recently started sprint.
|
||||
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;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
status: 'planning' | 'active' | 'completed';
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
@ -140,7 +139,6 @@ export interface Database {
|
||||
goal?: string | null;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
status: 'planning' | 'active' | 'completed';
|
||||
created_at?: string;
|
||||
};
|
||||
Update: {
|
||||
@ -149,7 +147,6 @@ export interface Database {
|
||||
goal?: string | null;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
status?: 'planning' | 'active' | 'completed';
|
||||
created_at?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@ -105,7 +105,7 @@ interface TaskStore {
|
||||
selectTask: (id: string | null) => void
|
||||
|
||||
// Sprint actions
|
||||
addSprint: (sprint: Omit<Sprint, 'id' | 'createdAt'>) => void
|
||||
addSprint: (sprint: Omit<Sprint, 'id' | 'createdAt' | 'status'>) => void
|
||||
updateSprint: (id: string, updates: Partial<Sprint>) => void
|
||||
deleteSprint: (id: string) => void
|
||||
selectSprint: (id: string | null) => void
|
||||
@ -547,10 +547,16 @@ export const useTaskStore = create<TaskStore>()(
|
||||
addSprint: (sprint) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const payload = {
|
||||
name: sprint.name,
|
||||
goal: sprint.goal,
|
||||
startDate: sprint.startDate,
|
||||
endDate: sprint.endDate,
|
||||
}
|
||||
await requestApi('/api/sprints', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sprint),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
await get().syncFromServer()
|
||||
} catch (error) {
|
||||
@ -564,10 +570,16 @@ export const useTaskStore = create<TaskStore>()(
|
||||
updateSprint: (id, updates) => {
|
||||
void (async () => {
|
||||
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', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, ...updates }),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
await get().syncFromServer()
|
||||
} catch (error) {
|
||||
|
||||
@ -16,15 +16,14 @@ BEGIN
|
||||
FOR ended_sprint IN
|
||||
SELECT id
|
||||
FROM public.sprints
|
||||
WHERE status <> 'completed'
|
||||
AND end_date < CURRENT_DATE
|
||||
WHERE end_date < CURRENT_DATE
|
||||
ORDER BY end_date ASC, start_date ASC
|
||||
LOOP
|
||||
-- Pick the next non-completed sprint globally.
|
||||
-- Pick the next sprint that has not yet ended.
|
||||
SELECT s.id
|
||||
INTO next_sprint_id
|
||||
FROM public.sprints s
|
||||
WHERE s.status <> 'completed'
|
||||
WHERE s.end_date >= CURRENT_DATE
|
||||
AND s.id <> ended_sprint.id
|
||||
ORDER BY s.start_date ASC
|
||||
LIMIT 1;
|
||||
@ -36,9 +35,6 @@ BEGIN
|
||||
AND status NOT IN ('done', 'canceled', 'archived');
|
||||
END IF;
|
||||
|
||||
UPDATE public.sprints
|
||||
SET status = 'completed'
|
||||
WHERE id = ended_sprint.id;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
@ -6,6 +6,7 @@ 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;
|
||||
ALTER TABLE public.sprints DROP COLUMN IF EXISTS status;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.complete_ended_sprints_and_rollover()
|
||||
RETURNS void
|
||||
@ -20,14 +21,13 @@ BEGIN
|
||||
FOR ended_sprint IN
|
||||
SELECT id
|
||||
FROM public.sprints
|
||||
WHERE status <> 'completed'
|
||||
AND end_date < CURRENT_DATE
|
||||
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.status <> 'completed'
|
||||
WHERE s.end_date >= CURRENT_DATE
|
||||
AND s.id <> ended_sprint.id
|
||||
ORDER BY s.start_date ASC
|
||||
LIMIT 1;
|
||||
@ -39,9 +39,6 @@ BEGIN
|
||||
AND status NOT IN ('done', 'canceled', 'archived');
|
||||
END IF;
|
||||
|
||||
UPDATE public.sprints
|
||||
SET status = 'completed'
|
||||
WHERE id = ended_sprint.id;
|
||||
END LOOP;
|
||||
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,
|
||||
start_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()
|
||||
);
|
||||
|
||||
@ -261,15 +260,14 @@ BEGIN
|
||||
FOR ended_sprint IN
|
||||
SELECT id
|
||||
FROM public.sprints
|
||||
WHERE status <> 'completed'
|
||||
AND end_date < CURRENT_DATE
|
||||
WHERE end_date < CURRENT_DATE
|
||||
ORDER BY end_date ASC, start_date ASC
|
||||
LOOP
|
||||
-- Pick the next non-completed sprint globally.
|
||||
-- Pick the next sprint that has not yet ended.
|
||||
SELECT s.id
|
||||
INTO next_sprint_id
|
||||
FROM public.sprints s
|
||||
WHERE s.status <> 'completed'
|
||||
WHERE s.end_date >= CURRENT_DATE
|
||||
AND s.id <> ended_sprint.id
|
||||
ORDER BY s.start_date ASC
|
||||
LIMIT 1;
|
||||
@ -281,9 +279,6 @@ BEGIN
|
||||
AND status NOT IN ('done', 'canceled', 'archived');
|
||||
END IF;
|
||||
|
||||
UPDATE public.sprints
|
||||
SET status = 'completed'
|
||||
WHERE id = ended_sprint.id;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user