From 64bcbb1b973eda00e85865e6a8f5e65116e5c490 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Tue, 24 Feb 2026 22:18:31 -0600 Subject: [PATCH] Signed-off-by: OpenClaw Bot --- .env.example | 1 + README.md | 1 + lib/data/gantt-api.ts | 78 ++++++++++++++++++++++++------ lib/data/gantt-snapshot.ts | 30 ++++++++++++ lib/data/projects.ts | 99 +++----------------------------------- lib/data/tasks.ts | 15 ++---- 6 files changed, 105 insertions(+), 119 deletions(-) create mode 100644 lib/data/gantt-snapshot.ts diff --git a/.env.example b/.env.example index f3e9dae..edb4c16 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-secret-key-here # Docker example: http://gantt-board:3000/api GANTT_API_BASE_URL=http://localhost:3000/api GANTT_API_BEARER_TOKEN=replace_with_same_value_as_gantt_machine_token +GANTT_API_REVALIDATE_SECONDS=15 # Optional link targets for UI NEXT_PUBLIC_GANTT_BOARD_URL=http://localhost:3000 diff --git a/README.md b/README.md index 187c17a..d04bfdb 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= GANTT_API_BASE_URL= # ex: http://gantt-board:3000/api (Docker network) +GANTT_API_REVALIDATE_SECONDS=15 # Optional cache window for gantt API reads # Optional NEXT_PUBLIC_GOOGLE_CLIENT_ID= # For Calendar integration diff --git a/lib/data/gantt-api.ts b/lib/data/gantt-api.ts index 3033a2b..42264a5 100644 --- a/lib/data/gantt-api.ts +++ b/lib/data/gantt-api.ts @@ -1,4 +1,13 @@ const DEFAULT_GANTT_API_BASE_URL = "http://localhost:3000/api"; +const DEFAULT_REVALIDATE_SECONDS = 15; + +type CacheEntry = { + expiresAt: number; + value: unknown; + promise?: Promise; +}; + +const responseCache = new Map(); function normalizeBaseUrl(url: string): string { return url.replace(/\/+$/, ""); @@ -12,6 +21,11 @@ export function getGanttApiBaseUrl(): string { return normalizeBaseUrl(configured); } +function getRevalidateSeconds(): number { + const raw = Number(process.env.GANTT_API_REVALIDATE_SECONDS || String(DEFAULT_REVALIDATE_SECONDS)); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_REVALIDATE_SECONDS; +} + function buildAuthHeaders(): HeadersInit { const headers: Record = {}; @@ -28,23 +42,55 @@ function buildAuthHeaders(): HeadersInit { export async function fetchGanttApi(endpoint: string): Promise { const baseUrl = getGanttApiBaseUrl(); - const response = await fetch(`${baseUrl}${endpoint}`, { - method: "GET", - cache: "no-store", - headers: { - Accept: "application/json", - ...buildAuthHeaders(), - }, - }); + const revalidateSeconds = getRevalidateSeconds(); + const cacheKey = `${baseUrl}${endpoint}`; + const now = Date.now(); + const cached = responseCache.get(cacheKey); - const payload = (await response.json().catch(() => null)) as - | { error?: string; message?: string } - | null; - - if (!response.ok) { - const details = payload?.error || payload?.message || response.statusText; - throw new Error(`gantt-board API request failed (${response.status} ${endpoint}): ${details}`); + if (cached && cached.expiresAt > now) { + if (cached.promise) { + return (await cached.promise) as T; + } + return cached.value as T; } - return payload as T; + const requestPromise = (async () => { + const response = await fetch(cacheKey, { + method: "GET", + cache: "no-store", + headers: { + Accept: "application/json", + ...buildAuthHeaders(), + }, + }); + + const payload = (await response.json().catch(() => null)) as + | { error?: string; message?: string } + | null; + + if (!response.ok) { + const details = payload?.error || payload?.message || response.statusText; + throw new Error(`gantt-board API request failed (${response.status} ${endpoint}): ${details}`); + } + + return payload as T; + })(); + + responseCache.set(cacheKey, { + expiresAt: now + revalidateSeconds * 1000, + value: null, + promise: requestPromise as Promise, + }); + + try { + const result = await requestPromise; + responseCache.set(cacheKey, { + expiresAt: Date.now() + revalidateSeconds * 1000, + value: result as unknown, + }); + return result; + } catch (error) { + responseCache.delete(cacheKey); + throw error; + } } diff --git a/lib/data/gantt-snapshot.ts b/lib/data/gantt-snapshot.ts new file mode 100644 index 0000000..eea7a40 --- /dev/null +++ b/lib/data/gantt-snapshot.ts @@ -0,0 +1,30 @@ +import { cache } from "react"; +import { fetchGanttApi } from "@/lib/data/gantt-api"; +import type { Task } from "@/lib/data/tasks"; +import type { Project, Sprint } from "@/lib/data/projects"; + +interface SnapshotResponse { + tasks?: Task[]; + projects?: Project[]; + sprints?: Sprint[]; +} + +export interface GanttSnapshot { + tasks: Task[]; + projects: Project[]; + sprints: Sprint[]; +} + +export const fetchGanttSnapshot = cache(async (): Promise => { + try { + const response = await fetchGanttApi("/tasks?scope=all"); + return { + tasks: Array.isArray(response.tasks) ? response.tasks : [], + projects: Array.isArray(response.projects) ? response.projects : [], + sprints: Array.isArray(response.sprints) ? response.sprints : [], + }; + } catch (error) { + console.error("Error fetching gantt snapshot from API:", error); + return { tasks: [], projects: [], sprints: [] }; + } +}); diff --git a/lib/data/projects.ts b/lib/data/projects.ts index fe020db..f000961 100644 --- a/lib/data/projects.ts +++ b/lib/data/projects.ts @@ -1,5 +1,5 @@ -import { fetchGanttApi } from "@/lib/data/gantt-api"; import { Task, fetchAllTasks } from "./tasks"; +import { fetchGanttSnapshot } from "@/lib/data/gantt-snapshot"; export interface Project { id: string; @@ -30,74 +30,9 @@ export interface ProjectStats { recentTasks: Task[]; } -interface GanttProjectsResponse { - projects?: Array<{ - id: string; - name: string; - description?: string | null; - color?: string | null; - created_at?: string; - createdAt?: string; - }>; -} - -interface GanttSprintsResponse { - sprints?: Array<{ - id: string; - name: string; - status: Sprint["status"]; - start_date?: string; - end_date?: string; - startDate?: string; - endDate?: string; - project_id?: string; - projectId?: string; - goal?: string | null; - }>; -} - -interface GanttCurrentSprintResponse { - sprint?: { - id: string; - name: string; - status: Sprint["status"]; - startDate: string; - endDate: string; - projectId: string; - goal?: string | null; - } | null; -} - -function mapProject(project: NonNullable[number]): Project { - return { - id: project.id, - name: project.name, - description: project.description ?? undefined, - color: project.color || "#3b82f6", - createdAt: project.createdAt || project.created_at || new Date().toISOString(), - }; -} - -function mapSprint(sprint: NonNullable[number]): Sprint { - return { - id: sprint.id, - name: sprint.name, - status: sprint.status, - startDate: sprint.startDate || sprint.start_date || new Date().toISOString(), - endDate: sprint.endDate || sprint.end_date || new Date().toISOString(), - projectId: sprint.projectId || sprint.project_id || "", - goal: sprint.goal ?? undefined, - }; -} - export async function fetchAllProjects(): Promise { - try { - const response = await fetchGanttApi("/projects"); - return Array.isArray(response.projects) ? response.projects.map(mapProject) : []; - } catch (error) { - console.error("Error fetching projects from gantt-board API:", error); - return []; - } + const snapshot = await fetchGanttSnapshot(); + return snapshot.projects; } export async function countProjects(): Promise { @@ -106,13 +41,8 @@ export async function countProjects(): Promise { } export async function fetchAllSprints(): Promise { - try { - const response = await fetchGanttApi("/sprints"); - return Array.isArray(response.sprints) ? response.sprints.map(mapSprint) : []; - } catch (error) { - console.error("Error fetching sprints from gantt-board API:", error); - return []; - } + const snapshot = await fetchGanttSnapshot(); + return snapshot.sprints; } export async function fetchProjectSprints(projectId: string): Promise { @@ -121,22 +51,9 @@ export async function fetchProjectSprints(projectId: string): Promise } export async function fetchActiveSprint(): Promise { - try { - const response = await fetchGanttApi("/sprints/current"); - if (!response.sprint) return null; - return { - id: response.sprint.id, - name: response.sprint.name, - status: response.sprint.status, - startDate: response.sprint.startDate, - endDate: response.sprint.endDate, - projectId: response.sprint.projectId, - goal: response.sprint.goal ?? undefined, - }; - } catch (error) { - console.error("Error fetching current sprint from gantt-board API:", error); - return null; - } + const sprints = await fetchAllSprints(); + const active = sprints.find((sprint) => sprint.status === "active"); + return active || null; } export async function countSprintsByStatus(): Promise<{ planning: number; active: number; completed: number; total: number }> { diff --git a/lib/data/tasks.ts b/lib/data/tasks.ts index 7a68336..e10120a 100644 --- a/lib/data/tasks.ts +++ b/lib/data/tasks.ts @@ -1,4 +1,4 @@ -import { fetchGanttApi } from "@/lib/data/gantt-api"; +import { fetchGanttSnapshot } from "@/lib/data/gantt-snapshot"; export interface Task { id: string; @@ -27,10 +27,6 @@ export interface Task { attachments: unknown[]; } -interface GanttTasksResponse { - tasks?: Task[]; -} - export interface TaskStatusCounts { open: number; inProgress: number; @@ -60,13 +56,8 @@ function toDateOnly(value: string): string { } async function fetchTasksFromApi(): Promise { - try { - const response = await fetchGanttApi("/tasks?scope=all&include=detail"); - return Array.isArray(response.tasks) ? response.tasks : []; - } catch (error) { - console.error("Error fetching tasks from gantt-board API:", error); - return []; - } + const snapshot = await fetchGanttSnapshot(); + return snapshot.tasks; } export async function fetchAllTasks(): Promise {