Signed-off-by: Max <ai-agent@topdoglabs.com>

This commit is contained in:
Max 2026-02-25 13:59:10 -06:00
parent 95fe894ed4
commit 5dcac7c9e6
23 changed files with 320 additions and 246 deletions

View File

@ -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

View File

@ -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

View File

@ -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,16 +149,11 @@ 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"
log "Sprint: $sprint_name (ID: $sprint_id) - Derived status: $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
# Rollover applies only to completed (ended) sprints.
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
local current_sprint
@ -214,15 +187,10 @@ process_sprint_updates() {
log "Warning: No current active sprint found for rollover"
fi
fi
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

View File

@ -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

View File

@ -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"

View File

@ -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", () => {

View File

@ -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 });

View File

@ -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 })

View File

@ -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)

View File

@ -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) {

View File

@ -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"),
]);

View File

@ -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)

View File

@ -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"

View File

@ -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)

View File

@ -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)

View File

@ -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]
}

View 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
}

View File

@ -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;
};
};

View File

@ -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) {

View File

@ -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;
$$;

View File

@ -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;
$$;

View 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;

View File

@ -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;
$$;