diff --git a/app/api/leantime/status-labels/route.ts b/app/api/leantime/status-labels/route.ts index 44ede180..43f78e77 100644 --- a/app/api/leantime/status-labels/route.ts +++ b/app/api/leantime/status-labels/route.ts @@ -2,8 +2,10 @@ import { getServerSession } from "next-auth/next"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { NextResponse } from "next/server"; -// Simple in-memory cache for user IDs +// Simple in-memory cache for user IDs and status labels const userCache = new Map(); +const statusLabelsCache = new Map(); +const CACHE_TTL = 60000; // 1 minute cache TTL export async function GET() { const session = await getServerSession(authOptions); @@ -41,9 +43,15 @@ export async function GET() { console.log('User lookup response:', userData); if (userData.error === 'Too many requests per minute.') { + const retryAfter = userResponse.headers.get('retry-after') || '60'; return NextResponse.json( - { error: "Rate limit exceeded. Please try again in a minute." }, - { status: 429 } + { error: "Rate limit exceeded. Please try again later." }, + { + status: 429, + headers: { + 'Retry-After': retryAfter + } + } ); } @@ -59,6 +67,12 @@ export async function GET() { } } + // Check status labels cache + const cachedLabels = statusLabelsCache.get(leantimeUserId); + if (cachedLabels && (Date.now() - cachedLabels.timestamp) < CACHE_TTL) { + return NextResponse.json({ projects: cachedLabels.data }); + } + // Now fetch the status labels const response = await fetch('https://agilite.slm-lab.net/api/jsonrpc', { method: 'POST', @@ -82,18 +96,24 @@ export async function GET() { console.log('Leantime API Response Body:', responseText); if (!response.ok) { + if (response.status === 429) { + const retryAfter = response.headers.get('retry-after') || '60'; + return NextResponse.json( + { error: "Rate limit exceeded. Please try again later." }, + { + status: 429, + headers: { + 'Retry-After': retryAfter + } + } + ); + } if (response.status === 401) { return NextResponse.json({ error: "Unauthorized access to Leantime API" }, { status: 401 }); } if (response.status === 403) { return NextResponse.json({ error: "Forbidden access to Leantime API" }, { status: 403 }); } - if (response.status === 429) { - return NextResponse.json( - { error: "Rate limit exceeded. Please try again in a minute." }, - { status: 429 } - ); - } throw new Error(`Leantime API returned ${response.status}: ${responseText}`); } @@ -131,6 +151,12 @@ export async function GET() { // Sort projects by ID for consistency transformedProjects.sort((a, b) => a.projectId.localeCompare(b.projectId)); + // Cache the transformed data + statusLabelsCache.set(leantimeUserId, { + data: transformedProjects, + timestamp: Date.now() + }); + return NextResponse.json({ projects: transformedProjects }); } catch (error) { console.error('Detailed error in status labels fetch:', error); diff --git a/app/api/leantime/tasks/route.ts b/app/api/leantime/tasks/route.ts index 4608ad2c..d67a8a4d 100644 --- a/app/api/leantime/tasks/route.ts +++ b/app/api/leantime/tasks/route.ts @@ -2,15 +2,27 @@ import { getServerSession } from "next-auth/next"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { NextResponse } from "next/server"; -export async function GET() { - const session = await getServerSession(authOptions); +interface Task { + id: string; + headline: string; + projectName: string; + projectId: number; + status: string; + dueDate: string | null; + priority: number; + details: string | null; +} - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); +// Cache for user IDs to avoid repeated lookups +const userCache = new Map(); + +async function getLeantimeUserId(email: string): Promise { + // Check cache first + if (userCache.has(email)) { + return userCache.get(email)!; } try { - // Get user's tasks from Leantime const response = await fetch('https://agilite.slm-lab.net/api/jsonrpc', { method: 'POST', headers: { @@ -18,15 +30,64 @@ export async function GET() { 'X-API-Key': process.env.LEANTIME_TOKEN || '', }, body: JSON.stringify({ - method: 'leantime.rpc.Tasks.Tasks.getAll', jsonrpc: '2.0', + method: 'leantime.rpc.users.getAll', + id: 1, + }), + }); + + if (!response.ok) { + throw new Error('Failed to fetch users from Leantime'); + } + + const data = await response.json(); + const user = data.result.find((u: any) => u.email === email); + + if (user) { + // Cache the user ID + userCache.set(email, user.id); + // Clear cache after 5 minutes + setTimeout(() => userCache.delete(email), 5 * 60 * 1000); + return user.id; + } + + return null; + } catch (error) { + console.error('Error getting Leantime user ID:', error); + return null; + } +} + +export async function GET() { + try { + const session = await getServerSession(authOptions); + + if (!session || !session.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Get Leantime user ID + const leantimeUserId = await getLeantimeUserId(session.user.email); + + if (!leantimeUserId) { + return NextResponse.json({ error: "User not found in Leantime" }, { status: 404 }); + } + + // Get all tasks assigned to the user + const response = await fetch('https://agilite.slm-lab.net/api/jsonrpc', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': process.env.LEANTIME_TOKEN || '', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'leantime.rpc.Tickets.Tickets.getAllByUserId', id: 1, params: { - userId: session.user.id, - status: ['not_started', 'in_progress'], - limit: 10, - sort: 'dueDate', - order: 'ASC' + userId: leantimeUserId, + status: "all", + limit: 100 } }) }); @@ -41,15 +102,64 @@ export async function GET() { return NextResponse.json({ tasks: [] }); } - // Transform the tasks to match our interface - const tasks = data.result.map((task: any) => ({ - id: task.id, - headline: task.headline, - description: task.description, - status: task.status, - dueDate: task.dueDate, - priority: task.priority - })); + // Get project details to include project names + const projectsResponse = await fetch('https://agilite.slm-lab.net/api/jsonrpc', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': process.env.LEANTIME_TOKEN || '', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'leantime.rpc.Projects.getAll', + id: 1 + }) + }); + + if (!projectsResponse.ok) { + throw new Error('Failed to fetch projects from Leantime'); + } + + const projectsData = await projectsResponse.json(); + const projectsMap = new Map( + projectsData.result.map((project: any) => [project.id, project.name]) + ); + + // Transform and categorize the tasks + const tasks = data.result.map((task: any) => { + const dueDate = task.dateToFinish ? new Date(task.dateToFinish * 1000) : null; + const now = new Date(); + + let status = 'upcoming'; + if (dueDate && dueDate < now) { + status = 'overdue'; + } else if (task.status === 'done' || task.status === 'closed') { + status = 'completed'; + } else if (task.status === 'inprogress') { + status = 'in_progress'; + } + + return { + id: task.id, + headline: task.headline, + projectName: projectsMap.get(task.projectId) || `Project ${task.projectId}`, + projectId: task.projectId, + status: status, + dueDate: dueDate ? dueDate.toISOString() : null, + priority: task.priority, + details: task.description ? task.description.substring(0, 100) : null + }; + }); + + // Sort tasks by due date and status + tasks.sort((a: Task, b: Task) => { + if (a.status === 'overdue' && b.status !== 'overdue') return -1; + if (a.status !== 'overdue' && b.status === 'overdue') return 1; + if (!a.dueDate && !b.dueDate) return 0; + if (!a.dueDate) return 1; + if (!b.dueDate) return -1; + return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime(); + }); return NextResponse.json({ tasks }); } catch (error) { diff --git a/components/flow.tsx b/components/flow.tsx index c5b30b57..6f46f221 100644 --- a/components/flow.tsx +++ b/components/flow.tsx @@ -1,40 +1,48 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { RefreshCw } from "lucide-react"; +import { RefreshCw, ChevronDown, Filter } from "lucide-react"; import { useRouter } from "next/navigation"; import { useSession } from "next-auth/react"; -interface StatusLabel { +interface Task { id: string; - name: string; - statusType: string; - class: string; - sortKey: number; - kanbanCol: boolean | string; + headline: string; + projectName: string; + dueDate: string; + status: string; + details?: string; } -interface Project { - projectId: string; - labels: StatusLabel[]; +interface TaskGroup { + name: string; + count: number; + tasks: Task[]; } export function Flow() { - const [projects, setProjects] = useState([]); + const [taskGroups, setTaskGroups] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [refreshing, setRefreshing] = useState(false); + const [retryTimeout, setRetryTimeout] = useState(null); const router = useRouter(); const { data: session } = useSession(); - const fetchStatusLabels = async (isRefresh = false) => { + const fetchTasks = useCallback(async (isRefresh = false, retryCount = 0) => { try { if (isRefresh) { setRefreshing(true); } - const response = await fetch('/api/leantime/status-labels', { + + if (retryTimeout) { + clearTimeout(retryTimeout); + setRetryTimeout(null); + } + + const response = await fetch('/api/leantime/tasks', { cache: 'no-store', next: { revalidate: 0 }, }); @@ -44,32 +52,63 @@ export function Flow() { return; } + if (response.status === 429) { + const retryAfter = response.headers.get('Retry-After'); + const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1000 : Math.min(1000 * Math.pow(2, retryCount), 60000); + setError(`Rate limit exceeded. Retrying in ${Math.round(waitTime / 1000)} seconds...`); + const timeout = setTimeout(() => { + fetchTasks(isRefresh, retryCount + 1); + }, waitTime); + setRetryTimeout(timeout); + return; + } + if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to fetch status labels'); + throw new Error('Failed to fetch tasks'); } const data = await response.json(); - setProjects(data.projects || []); + + // Group tasks by status + const groups: { [key: string]: Task[] } = {}; + data.tasks.forEach((task: Task) => { + const status = task.status || 'No Status'; + if (!groups[status]) { + groups[status] = []; + } + groups[status].push(task); + }); + + // Convert to array format with counts + const formattedGroups = Object.entries(groups).map(([name, tasks]) => ({ + name, + count: tasks.length, + tasks + })); + + setTaskGroups(formattedGroups); setError(null); } catch (err) { - console.error('Error fetching status labels:', err); - const errorMessage = err instanceof Error ? err.message : 'Failed to fetch status labels'; - setError(errorMessage); + console.error('Error fetching tasks:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch tasks'); } finally { setLoading(false); setRefreshing(false); } - }; + }, [retryTimeout]); useEffect(() => { if (session) { - fetchStatusLabels(); - // Set up polling every 5 minutes - const interval = setInterval(() => fetchStatusLabels(), 300000); - return () => clearInterval(interval); + fetchTasks(); + const interval = setInterval(() => fetchTasks(), 300000); + return () => { + clearInterval(interval); + if (retryTimeout) { + clearTimeout(retryTimeout); + } + }; } - }, [session]); + }, [session, fetchTasks, retryTimeout]); return ( router.push('/flow')} > - Flow - + + 📋 My ToDos + +
+ +
- {loading &&

Loading status labels...

} + {loading &&

Loading tasks...

} {error && (

Error: {error}

- + {!retryTimeout && ( + + )}
)} {!loading && !error && ( -
- {projects.length === 0 ? ( -

No status labels found

+
+
+ + +
+ {taskGroups.length === 0 ? ( +

No tasks found

) : ( - projects.map((project) => ( -
-

Project {project.projectId}

-
- {project.labels.map((label) => ( + taskGroups.map((group) => ( +
+
+ + {group.name === 'overdue' && 🔥} +

+ {group.name.charAt(0).toUpperCase() + group.name.slice(1)} ({group.count}) +

+
+
+ {group.tasks.map((task) => (
-
-
-

{label.name}

- - {label.statusType} - + {group.name === 'overdue' && ( +
+ )} +
+
{task.projectName}
+
+ {task.headline} + {task.details && ( + // {task.details} + )} +
+
+ 🗓️ {new Date(task.dueDate).toLocaleDateString()}