From 5dcac7c9e6b6086b19632d958fa165c2ab50390b Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 25 Feb 2026 13:59:10 -0600 Subject: [PATCH] Signed-off-by: Max --- scripts/README.md | 7 +- scripts/gantt.sh | 13 +-- scripts/sprint-auto-status.sh | 116 ++++++++++---------------- scripts/sprint.sh | 11 +-- scripts/tests/refactor-cli-api.sh | 8 +- scripts/tests/sprintSelection.test.ts | 30 +++---- src/app/api/sprints/[id]/route.ts | 13 ++- src/app/api/sprints/close/route.ts | 17 +++- src/app/api/sprints/current/route.ts | 12 ++- src/app/api/sprints/route.ts | 76 +++++++++++++---- src/app/api/tasks/route.ts | 12 +-- src/app/page.tsx | 28 ++----- src/app/sprints/page.tsx | 21 +---- src/components/BacklogView.tsx | 8 +- src/components/SprintBoard.tsx | 4 +- src/lib/server/sprintSelection.ts | 38 +++------ src/lib/server/sprintState.ts | 59 +++++++++++++ src/lib/supabase/database.types.ts | 3 - src/stores/useTaskStore.ts | 18 +++- supabase/nightly_sprint_rollover.sql | 10 +-- supabase/remove_sprint_project_id.sql | 9 +- supabase/remove_sprint_status.sql | 42 ++++++++++ supabase/schema.sql | 11 +-- 23 files changed, 320 insertions(+), 246 deletions(-) create mode 100644 src/lib/server/sprintState.ts create mode 100644 supabase/remove_sprint_status.sql diff --git a/scripts/README.md b/scripts/README.md index e2c0dd9..ebb0c52 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -100,8 +100,8 @@ Bulk JSON format: ```bash ./sprint.sh list [--status ] [--active] [--json] ./sprint.sh get -./sprint.sh create --name "" [--goal "..."] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--status ] -./sprint.sh update [--name "..."] [--goal "..."] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--status ] +./sprint.sh create --name "" [--goal "..."] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] +./sprint.sh update [--name "..."] [--goal "..."] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] ./sprint.sh close ./sprint.sh delete ``` @@ -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=` 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 diff --git a/scripts/gantt.sh b/scripts/gantt.sh index e017398..81520c2 100755 --- a/scripts/gantt.sh +++ b/scripts/gantt.sh @@ -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 " - 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 Update sprint field - Fields: name, goal, startDate, endDate, - status + Fields: name, goal, startDate, endDate sprint close Close a sprint sprint delete Delete a sprint diff --git a/scripts/sprint-auto-status.sh b/scripts/sprint-auto-status.sh index b5b5dab..a3e4736 100755 --- a/scripts/sprint-auto-status.sh +++ b/scripts/sprint-auto-status.sh @@ -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 \ No newline at end of file +esac diff --git a/scripts/sprint.sh b/scripts/sprint.sh index 69addf0..a4e85b2 100755 --- a/scripts/sprint.sh +++ b/scripts/sprint.sh @@ -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 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 diff --git a/scripts/tests/refactor-cli-api.sh b/scripts/tests/refactor-cli-api.sh index ec83e62..c50c97b 100755 --- a/scripts/tests/refactor-cli-api.sh +++ b/scripts/tests/refactor-cli-api.sh @@ -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" diff --git a/scripts/tests/sprintSelection.test.ts b/scripts/tests/sprintSelection.test.ts index 38ac7f9..3de879e 100644 --- a/scripts/tests/sprintSelection.test.ts +++ b/scripts/tests/sprintSelection.test.ts @@ -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", () => { diff --git a/src/app/api/sprints/[id]/route.ts b/src/app/api/sprints/[id]/route.ts index 0365950..439ae6c 100644 --- a/src/app/api/sprints/[id]/route.ts +++ b/src/app/api/sprints/[id]/route.ts @@ -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 }); diff --git a/src/app/api/sprints/close/route.ts b/src/app/api/sprints/close/route.ts index 34d3a95..7c389ea 100644 --- a/src/app/api/sprints/close/route.ts +++ b/src/app/api/sprints/close/route.ts @@ -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 }) diff --git a/src/app/api/sprints/current/route.ts b/src/app/api/sprints/current/route.ts index 3530a8f..2e24ef1 100644 --- a/src/app/api/sprints/current/route.ts +++ b/src/app/api/sprints/current/route.ts @@ -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) diff --git a/src/app/api/sprints/route.ts b/src/app/api/sprints/route.ts index 159836b..725b382 100644 --- a/src/app/api/sprints/route.ts +++ b/src/app/api/sprints/route.ts @@ -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, now: Date): Record { + 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, 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, 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, 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, new Date()), + }); } catch (error) { console.error(">>> API PATCH /sprints error:", error); if (error instanceof HttpError) { diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index d44df9d..bb9dabc 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -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): Project { } function mapSprintRow(row: Record): 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"), ]); diff --git a/src/app/page.tsx b/src/app/page.tsx index 0f5d9a2..0a7ba11 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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) diff --git a/src/app/sprints/page.tsx b/src/app/sprints/page.tsx index 694796d..11732bd 100644 --- a/src/app/sprints/page.tsx +++ b/src/app/sprints/page.tsx @@ -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 = { @@ -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() { -
- - -
-